diff --git a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java index e75bdb50..f7e6386a 100644 --- a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java +++ b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java @@ -51,6 +51,7 @@ import org.springblade.core.oauth2.service.OAuth2UserService; import org.springblade.system.feign.IAuthLockClient; import org.springblade.system.feign.IAuthLogClient; import org.springblade.core.tenant.BladeTenantProperties; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.IUserClient; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -117,8 +118,8 @@ public class BladeAuthConfiguration { } @Bean - public OAuth2UserService oAuth2UserService(IUserClient userClient) { - return new BladeUserDetailService(userClient); + public OAuth2UserService oAuth2UserService(IUserClient userClient, IMpUserClient mpUserClient) { + return new BladeUserDetailService(userClient, mpUserClient); } @Primary diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java index 8e207ad1..32e1e1e3 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java @@ -33,6 +33,8 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.utils.MpUserAuth; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; @@ -72,7 +74,12 @@ public class OAuth2UserInfoResponseAdvice implements ResponseBodyAdvice if (userInfo == null) { return body; } - userInfo.setPermission(loadPermission(bladeUser.getRoleId())); + if (MpUserAuth.isMpUser(bladeUser)) { + // 小程序用户:按身份直接生成权限,不走系统角色菜单 + userInfo.setPermission(MpUserIdentity.toPermissions(bladeUser.getRoleName())); + } else { + userInfo.setPermission(loadPermission(bladeUser.getRoleId())); + } return userInfo; } diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java index b48ac804..dfa3b898 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java @@ -88,7 +88,8 @@ public class Oauth2SmsEndpoint { return R.fail(USER_PHONE_NOT_FOUND); } // 校验手机是否已注册,防止恶意发送验证码 - OAuth2Request request = OAuth2Request.create(); + // 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) { diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 1bdd67bf..33de5291 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -2,10 +2,12 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.springblade.auth.utils.TokenUtil; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; import org.springblade.core.oauth2.handler.PasswordHandler; import org.springblade.core.oauth2.provider.OAuth2Request; +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; @@ -13,7 +15,8 @@ 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.IUserClient; +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; @@ -25,7 +28,8 @@ import org.springframework.stereotype.Component; * 微信小程序手机号一键登录。 *

* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。 - * 流程:换 openid + 手机号 → 按手机号查用户(不存在则拒绝)→ 记录 openid → 发令牌(与密码登录一致)。 + * 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝) + * → 记录 openid → 按身份发令牌(不依赖系统角色)。 */ @Slf4j @Component @@ -33,17 +37,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final IWechatMiniService wechatMiniService; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; public WechatMiniTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, IWechatMiniService wechatMiniService, - IUserClient userClient) { + IMpUserClient mpUserClient) { super(clientService, userService, passwordHandler); this.userService = userService; this.wechatMiniService = wechatMiniService; - this.userClient = userClient; + this.mpUserClient = mpUserClient; } @Override @@ -54,7 +58,7 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { @Override public OAuth2User user(OAuth2Request request) { // 先校验客户端是否允许该授权类型,避免先调微信再因客户端配置失败 - var oauthClient = client(request); + OAuth2Client oauthClient = client(request); HttpServletRequest httpRequest = WebUtil.getRequest(); String loginCode = httpRequest.getParameter("loginCode"); @@ -78,16 +82,21 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { throw new UserInvalidException("未获取到微信手机号"); } - OAuth2User user = userService.loadByPhone(phone, request); - if (!userService.validateUser(user)) { + R mpUserResult = mpUserClient.userInfoByPhone(request.getTenantId(), phone); + if (mpUserResult == null || !mpUserResult.isSuccess() || mpUserResult.getData() == null + || mpUserResult.getData().getUser() == null) { throw new UserInvalidException("用户不存在,无法登录"); } - R bindResult = userClient.bindWxMiniOpenId( + OAuth2User user = TokenUtil.convertMpUser(mpUserResult.getData()); + if (!userService.validateUser(user)) { + throw new UserInvalidException("用户身份无效,无法登录"); + } + + R bindResult = mpUserClient.bindOpenId( request.getTenantId(), Func.toLong(user.getUserId()), - session.getOpenid(), - phone + session.getOpenid() ); if (bindResult == null || !bindResult.isSuccess()) { String msg = bindResult != null ? bindResult.getMsg() : "绑定 openid 失败"; diff --git a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java index 1efee4d4..22904efe 100644 --- a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java +++ b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java @@ -18,9 +18,6 @@ * Using this software signifies agreement to this License, and the software * must not be used for illegal purposes. *

- * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is - * not liable for any claims arising from secondary or illegal development. - *

* Author: Chill Zhuang (bladejava@qq.com) */ package org.springblade.auth.service; @@ -35,8 +32,11 @@ import org.springblade.core.oauth2.utils.OAuth2ExceptionUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; +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; @@ -49,63 +49,121 @@ import java.util.Optional; @RequiredArgsConstructor public class BladeUserDetailService implements OAuth2UserService { private final IUserClient userClient; + private final IMpUserClient mpUserClient; @Override public OAuth2User loadByUserId(String userId, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); - - // 获取用户信息 - R result = userClient.userInfo(Func.toLong(userId), userType); - if (!result.isSuccess()) { - OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + // 小程序刷新/鉴权:User-Type=mp 时只查 blade_mp_user + if (isMpLoginRequest(request)) { + OAuth2User mpUser = loadMpUserById(userId); + if (mpUser == null) { + OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + } + return mpUser; } - // 构建oauth2用户信息 - return TokenUtil.convertUser(result.getData(), request); + + String userType = resolveUserType(request); + R result = userClient.userInfo(Func.toLong(userId), userType); + if (result != null && result.isSuccess() && result.getData() != null + && result.getData().getUser() != null) { + return TokenUtil.convertUser(result.getData(), request); + } + + // 兼容:刷新令牌未带 User-Type=mp 时,回落查询小程序用户 + OAuth2User mpUser = loadMpUserById(userId); + if (mpUser != null) { + return mpUser; + } + OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + return null; } @Override public OAuth2User loadByUsername(String username, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); - // 获取用户信息 + // 小程序账号密码 / 图形验证码登录:仅查 blade_mp_user + if (isMpLoginRequest(request)) { + R mpResult = mpUserClient.userInfoByAccount(tenantId, username); + if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) { + return null; + } + return TokenUtil.convertMpUser(mpResult.getData()); + } + + String userType = resolveUserType(request); R result = userClient.userInfo(tenantId, username, userType); - if (!result.isSuccess()) { + if (result == null || !result.isSuccess()) { return null; } - // 构建oauth2用户信息 return TokenUtil.convertUser(result.getData(), request); } @Override public OAuth2User loadByPhone(String phone, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); - // 获取用户信息 - R result = userClient.userInfoByPhone(tenantId, phone, userType); - if (!result.isSuccess()) { - return null; + // 小程序短信登录:仅查 blade_mp_user + if (isMpLoginRequest(request)) { + return loadMpUserByPhone(tenantId, phone); } - // 构建oauth2用户信息 - return TokenUtil.convertUser(result.getData(), request); + + String userType = resolveUserType(request); + R result = userClient.userInfoByPhone(tenantId, phone, userType); + if (result != null && result.isSuccess() && result.getData() != null) { + return TokenUtil.convertUser(result.getData(), request); + } + // 发短信校验等未带 User-Type 时,回落小程序用户,避免 mp 账号收不到验证码 + return loadMpUserByPhone(tenantId, phone); } @Override public boolean validateUser(OAuth2User user) { return Optional.ofNullable(user) - .filter(u -> u.getUserId() != null && !u.getUserId().isEmpty()) // 检查userId不为空 - .filter(u -> u.getAuthorities() != null && !u.getAuthorities().isEmpty()) // 检查authorities不为空 - .isPresent(); // 如果上述条件都满足,则返回true,否则返回false + .filter(u -> u.getUserId() != null && !u.getUserId().isEmpty()) + .filter(u -> u.getAuthorities() != null && !u.getAuthorities().isEmpty()) + .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)) + .orElse(UserType.WEB.getName()); + } + + private OAuth2User loadMpUserById(String userId) { + if (Func.isBlank(userId)) { + return null; + } + try { + R result = mpUserClient.userInfoById(Func.toLong(userId)); + if (result != null && result.isSuccess() && result.getData() != null + && result.getData().getUser() != null) { + return TokenUtil.convertMpUser(result.getData()); + } + } catch (Exception exception) { + // 非小程序用户场景忽略 + } + return null; + } + + private OAuth2User loadMpUserByPhone(String tenantId, String phone) { + R result = mpUserClient.userInfoByPhone(tenantId, phone); + if (result == null || !result.isSuccess() || result.getData() == null) { + return null; + } + return TokenUtil.convertMpUser(result.getData()); } } diff --git a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java index b39311da..c4380b43 100644 --- a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java +++ b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java @@ -28,12 +28,15 @@ package org.springblade.auth.utils; import org.springblade.core.oauth2.provider.OAuth2Request; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.impl.OAuth2UserDetail; +import org.springblade.core.tool.support.Kv; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.entity.UserInfo; - -import java.util.Collections; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.utils.MpUserAuth; /** * 认证工具类 @@ -75,6 +78,7 @@ public class TokenUtil { userDetail.setRealName(user.getRealName()); userDetail.setAccount(user.getAccount()); userDetail.setPassword(user.getPassword()); + userDetail.setPhone(user.getPhone()); userDetail.setDeptId(user.getDeptId()); userDetail.setPostId(user.getPostId()); userDetail.setRoleId(user.getRoleId()); @@ -85,4 +89,40 @@ public class TokenUtil { return userDetail; } + /** + * 小程序用户转换为OAuth2标准用户(身份直接写入 authorities,不走系统角色) + * + * @param userInfo 小程序用户信息 + * @return OAuth2User + */ + public static OAuth2User convertMpUser(MpUserInfo userInfo) { + if (userInfo == null || userInfo.getUser() == null) { + return null; + } + MpUser user = userInfo.getUser(); + OAuth2UserDetail userDetail = new OAuth2UserDetail(); + userDetail.setUserId(String.valueOf(user.getId())); + userDetail.setTenantId(user.getTenantId()); + userDetail.setName(user.getName()); + userDetail.setRealName(user.getRealName()); + userDetail.setAccount(user.getAccount()); + userDetail.setPassword(user.getPassword()); + userDetail.setPhone(user.getPhone()); + // 严格令牌模式要求 deptId/roleId 非空且 >=0;小程序用户用占位值,身份写在 roleName/authorities + userDetail.setDeptId(Func.toStr(user.getDeptId(), MpUserAuth.PLACEHOLDER_ID)); + if (Func.isBlank(userDetail.getDeptId()) || "-1".equals(userDetail.getDeptId())) { + userDetail.setDeptId(MpUserAuth.PLACEHOLDER_ID); + } + userDetail.setPostId(Func.toStr(user.getPostId(), MpUserAuth.PLACEHOLDER_ID)); + userDetail.setRoleId(MpUserAuth.PLACEHOLDER_ID); + userDetail.setRoleName(Func.join(userInfo.getIdentities())); + userDetail.setAvatar(user.getAvatar()); + userDetail.setAuthorities(userInfo.getIdentities()); + userDetail.setPermissions(userInfo.getPermissions()); + userDetail.setDetail(userInfo.getDetail() == null + ? Kv.create().set("type", MpUserIdentity.DETAIL_TYPE) + : userInfo.getDetail()); + return userDetail; + } + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java index 18338ed0..15a3e94a 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java @@ -144,6 +144,12 @@ public class Dept extends TenantEntity { @Schema(description = "金蝶部门代码") private String kingdeeDeptCode; + /** + * 合同编码前缀(合同编号:前缀(年份)三位流水号) + */ + @Schema(description = "合同编码前缀") + private String contractCodePrefix; + /** * 组织简称 */ @@ -210,4 +216,12 @@ public class Dept extends TenantEntity { @TableField(updateStrategy = FieldStrategy.NOT_NULL) private String oaSupSubComId; + /** + * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 + */ + @JsonIgnore + @Schema(description = "OA上次更新时间", hidden = true) + @TableField(updateStrategy = FieldStrategy.NOT_NULL) + private String oaModified; + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java index 2afb375e..af31a055 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java @@ -25,6 +25,7 @@ */ package org.springblade.system.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; @@ -63,6 +64,7 @@ public class RailwayStation extends BaseEntity { * 电报略码 */ @Schema(description = "电报略码") + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String telegraphCode; /** * 车站名称 diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java index cf21c31b..86e522e3 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java @@ -4,29 +4,34 @@ import lombok.AllArgsConstructor; import lombok.Getter; /** - * 部门类型枚举 + * 组织类型枚举,与业务字典 organization_type 对齐 + * * @author bfhuange * @date 2024/8/26 */ @AllArgsConstructor @Getter public enum DeptCategory { - /** - * 公司 - */ - COMPANY(1, "公司"), - /** - * 部门 - */ - DEPT(2, "部门"), - ; + /** + * 集团 + */ + GROUP(1, "集团"), + /** + * 公司 + */ + COMPANY(2, "公司"), + /** + * 部门 + */ + DEPT(3, "部门"), + ; - /** - * 部门类型编码 - */ - private final Integer code; - /** - * 部门类型名称 - */ - private final String name; + /** + * 组织类型编码 + */ + private final Integer code; + /** + * 组织类型名称 + */ + private final String name; } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java index 3b37571b..05ca3571 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java @@ -35,8 +35,12 @@ public enum UserSex { if (name == null || name.isEmpty()) { return null; } + String sexName = name.trim(); + if ("-1".equals(sexName) || "0".equals(sexName) || "未知".equals(sexName)) { + return null; + } return Stream.of(values()) - .filter(e -> e.getName().equals(name)) + .filter(e -> e.getName().equals(sexName) || String.valueOf(e.getCode()).equals(sexName)) .map(UserSex::getCode) .findFirst().orElse(null); } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java index 590a8baf..d8390cf5 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java @@ -66,4 +66,7 @@ public class OaOrgSyncPageVO implements Serializable { @Schema(description = "是否已到最后一页") private Boolean finished; + + @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") + private String modifiedSince; } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java index b493bdf3..b57811da 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java @@ -63,4 +63,7 @@ public class OaPersonSyncPageVO implements Serializable { @Schema(description = "是否已到最后一页") private Boolean finished; + + @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") + private String modifiedSince; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java new file mode 100644 index 00000000..9d16ee83 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java @@ -0,0 +1,148 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * LocalDate 宽松反序列化,对齐 OCR / 司机侧日期归一化规则。 + *

+ * 兼容写法: + *

+ * + * @author Chill + */ +public class FlexibleLocalDateDeserializer extends JsonDeserializer { + + /** + * 4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(与 OcrFieldSupport 一致) + */ + private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})"); + + private static final Pattern COMPACT_PATTERN = Pattern.compile("(\\d{4})(\\d{2})(\\d{2})"); + + @Override + public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException { + String text = parser.getValueAsString(); + if (text == null) { + return null; + } + String value = text.trim(); + if (value.isEmpty()) { + return null; + } + // 「长期」等非日期文案:按空值处理,避免把无法解析的字符串打成 400 + if (value.contains("长期") || value.contains("永久")) { + return null; + } + boolean preferLast = isEndDateField(parser.getCurrentName()); + LocalDate parsed = parseFlexible(value, preferLast); + if (parsed != null) { + return parsed; + } + return (LocalDate) context.handleWeirdStringValue(LocalDate.class, value, + "期望日期格式 yyyy-MM-dd / yyyyMMdd / yyyy年M月d日 等 OCR 常见写法"); + } + + /** + * 解析宽松日期文本;区间时按 preferLast 取首段或末段。 + * + * @param value 原始文本 + * @param preferLast true 取止期,false 取起期 + * @return 合法 LocalDate;无法识别时返回 null + */ + public static LocalDate parseFlexible(String value, boolean preferLast) { + if (value == null) { + return null; + } + String text = value.trim(); + if (text.isEmpty()) { + return null; + } + List candidates = matchDates(text); + if (!candidates.isEmpty()) { + return preferLast ? candidates.get(candidates.size() - 1) : candidates.get(0); + } + // 去掉所有非数字后恰为 8 位时按 yyyyMMdd 处理(如 20260317、2026-0317 混杂) + String compact = text.replaceAll("\\D", ""); + if (compact.length() == 8) { + return toLocalDate(compact.substring(0, 4), compact.substring(4, 6), compact.substring(6, 8)); + } + Matcher compactMatcher = COMPACT_PATTERN.matcher(text); + if (compactMatcher.find()) { + return toLocalDate(compactMatcher.group(1), compactMatcher.group(2), compactMatcher.group(3)); + } + return null; + } + + private static List matchDates(String text) { + List dates = new ArrayList<>(); + Matcher matcher = DATE_PATTERN.matcher(text); + while (matcher.find()) { + LocalDate date = toLocalDate(matcher.group(1), matcher.group(2), matcher.group(3)); + if (date != null) { + dates.add(date); + } + } + return dates; + } + + private static LocalDate toLocalDate(String yearText, String monthText, String dayText) { + try { + int year = Integer.parseInt(yearText); + int month = Integer.parseInt(monthText); + int day = Integer.parseInt(dayText); + return LocalDate.of(year, month, day); + } catch (NumberFormatException | DateTimeException exception) { + return null; + } + } + + /** + * 止期字段名通常以 End / EndDate 结尾,区间文本应取最后一段日期。 + */ + private static boolean isEndDateField(String fieldName) { + if (fieldName == null || fieldName.isEmpty()) { + return false; + } + return fieldName.endsWith("End") || fieldName.endsWith("EndDate") + || fieldName.contains("EndDate") || fieldName.contains("ValidEnd"); + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java index d6dc67af..ec9e0caf 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java @@ -46,4 +46,7 @@ public class ExceptionDisposalFollowRequest implements Serializable { @Schema(description = "跟进说明") private String followContent; + @Schema(description = "附件JSON,[{\"name\":\"...\",\"url\":\"...\"}]") + private String attachmentsJson; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java new file mode 100644 index 00000000..b1f233f8 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java @@ -0,0 +1,63 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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 ExceptionDisposalUpdateRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "异常处置ID") + private Long id; + + @Schema(description = "异常类型") + private String exceptionType; + + @Schema(description = "异常原因") + private String exceptionReason; + + @Schema(description = "上报说明") + private String reportDescription; + + @Schema(description = "现场照片,逗号分隔") + private String scenePhotos; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java index 6b16994f..0ba2fba4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java @@ -30,7 +30,19 @@ public class NodeSubmitDTO implements Serializable { @Schema(description = "定位信息") private Location location; - @Schema(description = "凭证照片 URL 列表") + /** + * 带类型的打卡照片,类型由提交方显式给出;非空时优先于 photos。 + */ + @Schema(description = "带类型的照片列表,非空时优先于 photos") + private List photoItems; + + /** + * 旧版小程序提交的裸 URL 列表,类型只能按下标回推过程配置的凭证类型。 + *

+ * 与 photoItems 并存是为了不强制升级小程序:新版提交 photoItems,旧版仍走这里; + * 等旧版本覆盖率达标后由后续改造删除。 + */ + @Schema(description = "凭证照片 URL 列表,类型按下标回推") private List photos; @Schema(description = "重量(吨)") diff --git a/org/springblade/core/tool/node/TreeNode.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java similarity index 58% rename from org/springblade/core/tool/node/TreeNode.java rename to blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java index 114aa0c0..0e1aa212 100644 --- a/org/springblade/core/tool/node/TreeNode.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/OcrRecognizeRequest.java @@ -23,50 +23,31 @@ *

* Author: Chill Zhuang (bladejava@qq.com) */ -package org.springblade.core.tool.node; +package org.springblade.transport.pojo.dto; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import org.springblade.core.tool.utils.Func; import java.io.Serial; -import java.util.Objects; +import java.io.Serializable; /** - * 树型节点类 + * 证件 OCR 识别请求(小程序) * - * @author smallchill + * @author Chill */ @Data -public class TreeNode extends BaseNode { +@Schema(description = "证件OCR识别请求") +public class OcrRecognizeRequest implements Serializable { @Serial private static final long serialVersionUID = 1L; - private String title; - - @JsonSerialize(using = ToStringSerializer.class) - private Long key; - - @JsonSerialize(using = ToStringSerializer.class) - private Long value; - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - TreeNode other = (TreeNode) obj; - return Func.equals(this.getId(), other.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(id, parentId); - } + @Schema(description = "已上传图片的地址,必须是百度可访问的HTTP或HTTPS地址") + private String imageUrl; + @Schema(description = "证件类型:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证") + private String type; + @Schema(description = "正副面:front或back,默认front") + private String side; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java new file mode 100644 index 00000000..c33d030f --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java @@ -0,0 +1,98 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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.time.LocalDate; + +/** + * 司机端个人认证提交请求 + *

+ * 一次提交承载个人认证的**全部**内容(身份证 + 驾驶证 + 从业资格证), + * 三证一起提交、一起审核,不拆开。 + * + * @author Chill + */ +@Data +@Schema(description = "司机端个人认证提交请求") +public class PersonCertSubmitRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "真实姓名") + private String realName; + @Schema(description = "身份证号") + private String idCardNo; + @Schema(description = "身份证正面照") + private String idCardFrontUrl; + @Schema(description = "身份证反面照") + private String idCardBackUrl; + @Schema(description = "身份证有效期起") + private LocalDate idCardValidStart; + @Schema(description = "身份证有效期止") + private LocalDate idCardValidEnd; + @Schema(description = "身份证长期有效") + private Integer idCardLongTerm; + + @Schema(description = "驾驶证档案编号") + private String driverFileNo; + @Schema(description = "准驾车型") + private String driverClass; + @Schema(description = "驾驶证主页照片") + private String driverLicenseFrontUrl; + @Schema(description = "驾驶证副页照片") + private String driverLicenseBackUrl; + @Schema(description = "驾驶证有效期起") + private LocalDate driverValidStart; + @Schema(description = "驾驶证有效期止") + private LocalDate driverValidEnd; + @Schema(description = "驾驶证长期有效") + private Integer driverLongTerm; + + @Schema(description = "从业资格证类型") + private String qualificationType; + @Schema(description = "从业资格证号") + private String qualificationNo; + @Schema(description = "从业资格证封面页") + private String qualificationFront; + @Schema(description = "从业资格证内容页") + private String qualificationBack; + @Schema(description = "从业资格证有效期起") + private LocalDate qualificationValidStart; + @Schema(description = "从业资格证有效期止") + private LocalDate qualificationValidEnd; + @Schema(description = "从业资格证长期有效") + private Integer qualificationLongTerm; + + @Schema(description = "大头照") + private String headPhoto; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java new file mode 100644 index 00000000..d7c4e20a --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java @@ -0,0 +1,32 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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; + +/** + * 带类型的打卡照片 + *

+ * 类型取值域是固定的「打卡照片」加该节点过程配置勾选的凭证类型,域外类型一律拒绝: + * 类型是照片分组的唯一依据,放进来路不明的类型会让回显分组错乱。 + */ +@Data +@Schema(description = "带类型的打卡照片") +public class PunchPhotoDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "照片类型") + private String type; + + @Schema(description = "图片URL") + private String url; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java new file mode 100644 index 00000000..6cd65d78 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java @@ -0,0 +1,115 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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.time.LocalDate; + +/** + * 司机端车辆认证提交请求 + *

+ * 一次提交承载该车辆认证的**全部**内容(行驶证 + 道路运输证),两证一起提交、一起审核。 + * 提交只产生新的待审记录,不覆盖车辆当前已生效的认证内容。 + * + * @author Chill + */ +@Data +@Schema(description = "司机端车辆认证提交请求") +public class VehicleCertSubmitRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "车辆ID:新增时为空,重新认证时必填") + private Long id; + @Schema(description = "车牌号") + private String plateNo; + @Schema(description = "所有人:行驶证登记的单位名称,不是司机") + private String ownerName; + @Schema(description = "车牌颜色") + private String plateColor; + @Schema(description = "车辆类型") + private String vehicleType; + @Schema(description = "车辆识别代号(车架号),选填") + private String vin; + + @Schema(description = "外廓长度,毫米") + private Integer outerLength; + @Schema(description = "外廓宽度,毫米") + private Integer outerWidth; + @Schema(description = "外廓高度,毫米") + private Integer outerHeight; + @Schema(description = "核定载质量,KG") + private Integer approvedLoadKg; + @Schema(description = "准牵引总质量,KG") + private Integer tractionMassKg; + @Schema(description = "能源类型") + private String energyType; + @Schema(description = "业务关系:自有 / 管理挂靠 / 业务合作") + private String businessRelation; + @Schema(description = "强制报废日期") + private LocalDate compulsoryScrapDate; + @Schema(description = "强制报废长期有效:0否 1是") + private Integer compulsoryScrapLongTerm; + + @Schema(description = "行驶证档案编号") + private String drivingLicenseNo; + @Schema(description = "行驶证主页正面照片") + private String drivingLicenseImage; + @Schema(description = "行驶证主页反面照片") + private String drivingLicenseMainBack; + @Schema(description = "行驶证副页正面照片") + private String drivingLicenseViceFront; + @Schema(description = "行驶证副页反面照片") + private String drivingLicenseViceBack; + @Schema(description = "行驶证有效期起") + private LocalDate drivingLicenseStartDate; + @Schema(description = "行驶证有效期止") + private LocalDate drivingLicenseEndDate; + @Schema(description = "行驶证长期有效:0否 1是") + private Integer drivingLicenseLongTerm; + + @Schema(description = "道路运输证号") + private String roadTransportCertNo; + @Schema(description = "道路运输证照片") + private String roadTransportCertImage; + @Schema(description = "道路运输证有效期起") + private LocalDate roadTransportCertStartDate; + @Schema(description = "道路运输证有效期止") + private LocalDate roadTransportCertEndDate; + @Schema(description = "道路运输证长期有效:0否 1是") + private Integer roadTransportCertLongTerm; + + @Schema(description = "机动车登记编号") + private String registrationNo; + @Schema(description = "机动车登记本照片") + private String registrationImage; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java index e445395e..717766fa 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java @@ -32,6 +32,12 @@ public class WaybillImportBatchRequest { private Long planId; private String planName; private String remark; + /** 列表筛选:创建人 */ + private Long createUser; + /** 列表筛选:创建时间起(yyyy-MM-dd HH:mm:ss) */ + private String createTimeStart; + /** 列表筛选:创建时间止(yyyy-MM-dd HH:mm:ss) */ + private String createTimeEnd; private List> rows; /** 分批确认导入时,是否追加到已有批次(首批为 false,后续批为 true)。 */ private Boolean append; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java new file mode 100644 index 00000000..763f8f31 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java @@ -0,0 +1,114 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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.ArrayList; +import java.util.List; + +/** + * 管理端打卡记录编辑请求 + *

+ * 承载时间轴上单条打卡记录的全部可编辑项,字段按过程配置取舍: + * 打卡定位(地址/经纬度)、上传货量(重量/体积/数量)、上传凭证(照片)。 + *

+ * {@code punched=false} 表示取消打卡,服务端会清除该节点的打卡流水 + * ——时间轴上「未打卡」的判据就是没有流水,故不能只改状态位。 + * + * @author Chill + */ +@Data +@Schema(description = "管理端打卡记录编辑请求") +public class WaybillPunchRecordSaveRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID") + private Long waybillId; + + @Schema(description = "打卡类型:node=过程节点打卡,enroute=在途打卡") + private String type; + + @Schema(description = "过程节点 key") + private String nodeCode; + + @Schema(description = "过程节点名称") + private String nodeName; + + @Schema(description = "打卡状态:true 已打卡,false 未打卡") + private Boolean punched; + + @Schema(description = "打卡时间 yyyy-MM-dd HH:mm:ss,为空时取当前时间") + private String punchTime; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "经度") + private String longitude; + + @Schema(description = "纬度") + private String latitude; + + @Schema(description = "重量(吨)") + private String weight; + + @Schema(description = "体积(方)") + private String volume; + + @Schema(description = "数量(件)") + private String quantity; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "打卡凭证图,按上传顺序回写凭证类型") + private List photos = new ArrayList<>(); + + /** + * 打卡凭证图 + *

+ * 类型随图一起回写:展示层对「逗号分隔的裸 URL」按凭证类型下标回推标签, + * 编辑换了顺序就会串标,所以这里保留原类型。 + */ + @Data + @Schema(description = "打卡凭证图") + public static class Photo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "凭证类型") + private String type; + + @Schema(description = "图片URL") + private String url; + + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java index d56fec74..04f6a1b4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java @@ -61,10 +61,10 @@ public class ContractManage extends TenantEntity { @Schema(description = "所属项目") private String projectName; - @Schema(description = "所属组织ID") + @Schema(description = "合同负责部门ID") private Long organizationId; - @Schema(description = "所属组织") + @Schema(description = "合同负责部门") private String organizationName; @Schema(description = "合同类别") @@ -100,6 +100,9 @@ public class ContractManage extends TenantEntity { @Schema(description = "签订日期") private LocalDate signDate; + @Schema(description = "申请日期(提交正式合同时写入)") + private LocalDate applyDate; + @Schema(description = "结算方式") private String settlementMode; @@ -107,6 +110,7 @@ public class ContractManage extends TenantEntity { private String contractFormat; @Schema(description = "是否需要加盖法人章") + @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS) private Integer legalSealFlag; @Schema(description = "一式份数") @@ -206,7 +210,10 @@ public class ContractManage extends TenantEntity { @Schema(description = "终止原因") private String terminateReason; - @Schema(description = "备注") + @Schema(description = "合同主要内容") + private String mainContent; + + @Schema(description = "其他事项说明") private String remark; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java index 1d13e2ec..fc7c3ce8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java @@ -28,10 +28,15 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; +import org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; @@ -64,6 +69,7 @@ public class Driver extends TenantEntity { * 出生年月 */ @Schema(description = "出生年月") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate birthday; /** * 性别 @@ -111,6 +117,23 @@ public class Driver extends TenantEntity { */ @Schema(description = "身份证反面照") private String idCardBack; + /** + * 身份证有效期起 + */ + @Schema(description = "身份证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) + private LocalDate idCardValidStart; + /** + * 身份证有效期止 + */ + @Schema(description = "身份证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) + private LocalDate idCardValidEnd; + /** + * 身份证长期有效 + */ + @Schema(description = "身份证长期有效") + private Integer idCardLongTerm; /** * 大头照 */ @@ -130,11 +153,13 @@ public class Driver extends TenantEntity { * 驾驶证有效期起 */ @Schema(description = "驾驶证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseStartDate; /** * 驾驶证有效期止 */ @Schema(description = "驾驶证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseEndDate; /** * 驾驶证长期有效 @@ -165,6 +190,7 @@ public class Driver extends TenantEntity { * 从业资格证有效期止 */ @Schema(description = "从业资格证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate qualificationEndDate; /** * 从业资格证长期有效 @@ -192,9 +218,10 @@ public class Driver extends TenantEntity { @Schema(description = "手机号") private String mobile; /** - * 关联系统用户ID + * 关联小程序用户ID(blade_mp_user.id) */ - @Schema(description = "关联系统用户ID") + @Schema(description = "关联小程序用户ID") + @JsonSerialize(using = ToStringSerializer.class) private Long userId; /** * 与联系人关系 @@ -221,5 +248,32 @@ public class Driver extends TenantEntity { */ @Schema(description = "备注") private String remark; + /** + * 最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null 表示从未提交 + *

+ * 仅为列表展示而冗余,不是可用性判据;「是否已认证」一律读生效快照指针。 + */ + @Schema(description = "最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null为从未提交") + private Integer certificationStatus; + /** + * 最新一次驳回原因展示缓存 + */ + @Schema(description = "最新一次驳回原因展示缓存") + private String certificationRejectReason; + /** + * 生效快照指针:指向 blade_transport_cert_record.id + *

+ * 「该司机是否已认证」的唯一判据。为 null 表示从未认证通过; + * 一经设置永不回退——提交、驳回、作废都不得修改它, + * 因此重新提交认证不会让一个已认证的司机失去接单资格。 + *

+ * ⚠️ {@code READ_ONLY}:本字段只能由审核通过时写入。 + * 司机档案的 {@code /submit} 直接以本实体接收请求体,若允许反序列化, + * 调用方就能自行填一个记录 ID 把自己「认证」掉,绕过审核。 + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "生效快照指针") + private Long effectiveCertRecordId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java index 96f5647e..67588e27 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java @@ -51,6 +51,12 @@ public class ExceptionDisposal extends TenantEntity { @Schema(description = "运单号") private String waybillNo; + @Schema(description = "过程节点 key(为空表示未指明节点)") + private String nodeCode; + + @Schema(description = "过程节点名称(上报时复制,此后不随过程配置变化)") + private String nodeName; + @Schema(description = "配载单号/总单号") private String loadingOrMasterNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java index a9921786..e5593217 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java @@ -51,6 +51,9 @@ public class ExceptionDisposalFollowRecord extends TenantEntity { @Schema(description = "跟进说明") private String followContent; + @Schema(description = "附件JSON") + private String attachmentsJson; + @Schema(description = "跟进人") private Long followUser; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java index 5526da8e..eb85e2c4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java @@ -70,10 +70,10 @@ public class ProjectApply extends TenantEntity { @Schema(description = "业务部门") private String businessDeptName; - @Schema(description = "承办部门ID") - private Long undertakeDeptId; + @Schema(description = "平台公司ID,多个逗号分隔") + private String undertakeDeptId; - @Schema(description = "承办部门") + @Schema(description = "平台公司,多个顿号分隔") private String undertakeDeptName; @Schema(description = "项目由来") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java new file mode 100644 index 00000000..4f1346f4 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java @@ -0,0 +1,158 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.time.LocalDateTime; + +/** + * 认证记录实体类 + *

+ * 认证对象(司机 / 车辆)每一次提交所产生的、不可变的快照。 + * 记录一经创建,其 {@code snapshotJson} 与提交元信息永不修改;被同对象的后续提交取代时 + * 标记为「已作废」,而不是删除。判定某个对象「是否已认证」只看它持有的生效快照指针, + * 与记录表里的任何一条记录的状态都无关。 + *

+ * ⚠️ {@code status} 承载的是「审核状态」,不是 BladeX 的「启用 / 停用」。 + * 该字段继承自 {@link TenantEntity},带来两条必须堵住的框架通道: + * 一是 {@code BaseServiceImpl#resolveEntity} 在插入时会把 {@code null} 的 {@code status} + * 静默填成 {@code 1}(恰好是 {@link #STATUS_APPROVED}); + * 二是继承来的 {@code changeStatus(ids, 1)} 会直接写 {@code status},等于批量通过审核。 + * 二者都已在 {@code TransportCertRecordServiceImpl} 中封堵(前者校验抛错、后者覆写抛错)。 + * 审核必须逐条、带审核人与审核时间落库,不得借道 {@code changeStatus} 批量改状态。 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_transport_cert_record") +@Schema(description = "认证记录") +public class TransportCertRecord extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 认证对象类型:司机 + */ + public static final String BIZ_TYPE_DRIVER = "driver"; + /** + * 认证对象类型:车辆 + */ + public static final String BIZ_TYPE_VEHICLE = "vehicle"; + + /** + * 审核状态:审核中(即该对象的「待审快照」) + */ + public static final Integer STATUS_PENDING = 0; + /** + * 审核状态:已通过(该对象最新一条已通过者即其「生效快照」) + */ + public static final Integer STATUS_APPROVED = 1; + /** + * 审核状态:已驳回(审核的否定结论,需带驳回原因) + */ + public static final Integer STATUS_REJECTED = 2; + /** + * 审核状态:已作废(被同对象的再次提交取代,非驳回) + */ + public static final Integer STATUS_SUPERSEDED = 3; + + /** + * 驳回原因最大长度,与 {@code reject_reason} 列宽保持一致 + */ + public static final int REJECT_REASON_MAX_LENGTH = 200; + + /** + * 认证对象类型:driver-司机,vehicle-车辆 + *

+ * {@code NEVER}:记录一经创建,认证对象的身份不可改。 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @Schema(description = "认证对象类型:driver-司机,vehicle-车辆") + private String bizType; + /** + * 认证对象主键:司机ID 或 车辆ID(车辆按具体车辆,不是司机) + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "认证对象主键") + private Long bizId; + /** + * 展示键:司机姓名 / 车牌号,供审核列表直接展示 + */ + @Schema(description = "展示键:司机姓名 / 车牌号") + private String bizKey; + /** + * 该次提交的全量认证内容(JSON),一经创建不可修改 + *

+ * {@code NEVER}:本字段永不进入 UPDATE 语句,因此不存在「某条链路 + * 忘了保护快照」的可能。改内容只能靠新提交一条记录。 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @Schema(description = "该次提交的全量认证内容JSON") + private String snapshotJson; + /** + * 驳回原因,仅当审核状态为已驳回时有值 + */ + @Schema(description = "驳回原因") + private String rejectReason; + /** + * 提交时间 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @Schema(description = "提交时间") + private LocalDateTime submitTime; + /** + * 提交人 + */ + @TableField(updateStrategy = FieldStrategy.NEVER) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "提交人") + private Long submitUser; + /** + * 审核时间 + */ + @Schema(description = "审核时间") + private LocalDateTime auditTime; + /** + * 审核人 + */ + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "审核人") + private Long auditUser; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java index 4829cc33..71f5958a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java @@ -26,10 +26,15 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; +import org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; @@ -63,6 +68,11 @@ public class TransportVehicle extends TenantEntity { */ @Schema(description = "车牌号") private String plateNo; + /** + * 所有人:行驶证登记的单位名称,不是司机 + */ + @Schema(description = "所有人(行驶证登记的单位名称,不是司机)") + private String ownerName; /** * 车牌颜色 */ @@ -73,6 +83,11 @@ public class TransportVehicle extends TenantEntity { */ @Schema(description = "车辆类型") private String vehicleType; + /** + * 车辆识别代号(车架号) + */ + @Schema(description = "车辆识别代号(车架号)") + private String vin; /** * 外廓长度,毫米 */ @@ -112,6 +127,7 @@ public class TransportVehicle extends TenantEntity { * 强制报废日期 */ @Schema(description = "强制报废日期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate compulsoryScrapDate; /** * 强制报废长期有效 @@ -132,11 +148,13 @@ public class TransportVehicle extends TenantEntity { * 行驶证有效期起 */ @Schema(description = "行驶证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseStartDate; /** * 行驶证有效期止 */ @Schema(description = "行驶证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseEndDate; /** * 行驶证长期有效 @@ -172,11 +190,13 @@ public class TransportVehicle extends TenantEntity { * 道路运输证有效期起 */ @Schema(description = "道路运输证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate roadTransportCertStartDate; /** * 道路运输证有效期止 */ @Schema(description = "道路运输证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate roadTransportCertEndDate; /** * 道路运输证长期有效 @@ -192,6 +212,7 @@ public class TransportVehicle extends TenantEntity { * 道路运输年审有效期 */ @Schema(description = "道路运输年审有效期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate annualReviewEndDate; /** * 道路运输年审长期有效 @@ -207,6 +228,7 @@ public class TransportVehicle extends TenantEntity { * 机动车登记日期 */ @Schema(description = "机动车登记日期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate registrationDate; /** * 机动车登记本图片 @@ -224,5 +246,21 @@ public class TransportVehicle extends TenantEntity { /** 认证驳回原因 */ @Schema(description = "认证驳回原因") private String certificationRejectReason; + /** + * 生效快照指针:指向 blade_transport_cert_record.id + *

+ * 「该车辆是否已认证」的唯一判据。为 null 表示从未认证通过; + * 一经设置永不回退——重新提交、待审、被驳回都不得修改它, + * 因此重新提交认证不会让一辆已认证的车掉出可用池。 + * {@link #certificationStatus} 是「最新提交状态」的展示缓存,不代表是否已认证。 + *

+ * ⚠️ {@code READ_ONLY}:本字段只能由审核通过时写入。 + * {@code POST /transport-vehicle/submit} 直接以本实体接收请求体, + * 若允许反序列化,调用方就能自行填一个记录 ID 把自己「认证」掉,绕过审核。 + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "生效快照指针") + private Long effectiveCertRecordId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java index dec1a0b5..d79f1502 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java @@ -25,6 +25,8 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.NullSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -82,6 +84,7 @@ public class Waybill extends TenantEntity { @Schema(description = "型号") private String model; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "数量") private BigDecimal quantity; @@ -183,6 +186,7 @@ public class Waybill extends TenantEntity { @Schema(description = "押运人手机号") private String escortPhone; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "里程") private BigDecimal mileage; @@ -195,12 +199,14 @@ public class Waybill extends TenantEntity { @Schema(description = "预计完成日期") private LocalDate estimatedEndTime; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "单价") private BigDecimal unitPrice; @Schema(description = "计价单位") private String priceUnit; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "其他费用合计") private BigDecimal otherFeeTotal; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java index d37bd5bd..bb6081fb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java @@ -44,4 +44,7 @@ public class AdminHomeBadgesVO implements Serializable { @Schema(description = "风险记录待办数(disposalStatus=pending)") private long risk; + @Schema(description = "重新派单待办数(司机已拒单且运单未取消未完成)") + private long pendingReassign; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java index 2302825d..25c34e4a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java @@ -49,7 +49,7 @@ public class AdminHomeVO implements Serializable { @Schema(description = "快捷入口角标") private AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); - @Schema(description = "待处理事项(异常处置状态≠已完成)") + @Schema(description = "待处理事项(异常处置状态≠已完成,或司机已拒单待重新派单)") private List feed = new ArrayList<>(); } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java index d22af130..8b934612 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java @@ -56,4 +56,14 @@ public class BaiduOcrResultVO implements Serializable { @Schema(description = "百度OCR原始返回结果") private Map result; + /** + * 归一化后的业务字段 + *

+ * 键与提交请求里的字段同名({@code plateNo}、{@code ownerName}…),调用方直接回填即可, + * 不需要认识百度那套中文键名。只包含**识别到**的字段,没识别到的不出现(而不是给空串), + * 调用方据此就能区分「没识别到」与「识别到空值」,不会把用户已填的内容覆盖成空。 + */ + @Schema(description = "归一化后的业务字段,键与提交请求同名") + private Map fields; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java new file mode 100644 index 00000000..577ce62e --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java @@ -0,0 +1,97 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 认证审核详情视图 + *

+ * 审核是「拿新提交的内容和当前生效的内容比」的过程,所以这里**同时**给出两侧: + * {@code pendingSnapshotJson} 是本次待审的全量内容, + * {@code effectiveSnapshotJson} 是该对象当前生效的全量内容。 + * 前端对这两个 JSON 做字段级 diff,即可看出司机这次改了什么。 + *

+ * 两侧的 JSON 结构完全一致(同一套提交结构),因此可以逐键直接比较。 + * + * @author Chill + */ +@Data +@Schema(description = "认证审核详情") +public class CertAuditDetailVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "认证记录主键") + private Long id; + @Schema(description = "认证对象类型:driver-司机,vehicle-车辆") + private String bizType; + @Schema(description = "认证对象主键") + private Long bizId; + @Schema(description = "展示键:司机姓名 / 车牌号") + private String bizKey; + @Schema(description = "审核状态:0审核中 1已通过 2已驳回 3已作废") + private Integer status; + @Schema(description = "状态中文文案") + private String statusText; + @Schema(description = "本次提交时间") + private LocalDateTime submitTime; + @Schema(description = "本次提交人") + private Long submitUser; + @Schema(description = "本次提交人姓名") + private String submitUserName; + @Schema(description = "本次审核人;未审核时为 null") + private Long auditUser; + @Schema(description = "本次审核人姓名;未审核时为空") + private String auditUserName; + @Schema(description = "审核时间(通过或驳回的时间);未审核时为 null") + private LocalDateTime auditTime; + + @Schema(description = "待审快照:本次提交的全量内容") + private String pendingSnapshotJson; + @Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null") + private String effectiveSnapshotJson; + @Schema(description = "生效快照记录ID;null 表示该对象从未认证通过") + private Long effectiveRecordId; + + /** + * 该对象是否已认证 + *

+ * 审核页据此提示「本次驳回不会影响该对象继续使用」—— + * 已认证的对象即便本次被驳回,既得认证依然有效。 + */ + @TableField(exist = false) + @Schema(description = "该对象当前是否已认证") + private Boolean certified; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java index 4bca6317..691cb573 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java @@ -49,4 +49,7 @@ public class DriverNodePunchVO implements Serializable { @Schema(description = "数量") private String quantity; + @Schema(description = "本次打卡是否使运单自动完成") + private Boolean waybillCompleted; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index f7e88823..03f85510 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -4,6 +4,8 @@ */ package org.springblade.transport.pojo.vo; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -34,9 +36,37 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "是否已打卡(在途=今日已打)") private Boolean done; - @Schema(description = "当前是否可打卡(顺序门禁 + 在途频次时段)") + @Schema(description = "当前是否可打卡(已打卡、被异常阻断、在途频次时段都会置否)") private Boolean actionable; + @Schema(description = "是否被未了结的异常阻断:异常所在节点及其后的节点不可打卡;在途只要有未了结异常即不可打卡") + private Boolean blocked; + + @Schema(description = "是否因前置节点未打卡而被顺序门禁拦住(在途豁免,恒为否)") + private Boolean blockedByOrder; + + @Schema(description = "顺序门禁拦住的原因,如「请先完成装货打卡」") + private String orderLockReason; + + @Schema(description = "是否允许展开:顺序门禁不允许,异常门禁允许(可查看回填内容)") + private Boolean expandable; + + @Schema(description = "该节点自身是否被上报了未了结异常(仅异常所在节点为是,其后被牵连阻断的节点为否)") + private Boolean hasException; + + @Schema(description = "该运单是否存在未了结的整单异常;整单异常不归属任何节点,故每个节点都带此标记") + private Boolean hasWaybillException; + + @Schema(description = "与该节点相关的未了结异常ID,按上报时间升序;相关 = 归属该节点 ∪ 整单异常") + private List relatedExceptionIds = new ArrayList<>(); + + @Schema(description = "阻断该节点的异常处置ID(司机端「异常详情」跳转用);未被阻断或异常已不存在时为空") + @JsonSerialize(using = ToStringSerializer.class) + private Long blockedExceptionId; + + @Schema(description = "不可打卡的原因,供司机端展示") + private String lockReason; + @Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)") private Boolean visible; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java index 46bbfc7e..1e8d48ed 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java @@ -44,29 +44,89 @@ public class DriverVehicleCardVO implements Serializable { @Schema(description = "车牌号") private String plateNo; + @Schema(description = "所有人(行驶证登记的单位名称,不是司机)") + private String ownerName; + + @Schema(description = "车牌颜色") + private String plateColor; + @Schema(description = "车辆类型") private String vehicleType; - @Schema(description = "行驶证主页照片") - private String licenseFrontUrl; - - @Schema(description = "行驶证副页照片") - private String licenseBackUrl; - - @Schema(description = "道路运输证号") - private String roadTransportNo; - - @Schema(description = "道路运输证照片") - private String roadTransportUrl; - - @Schema(description = "车架号") + @Schema(description = "车辆识别代号(车架号),选填") private String vin; - @Schema(description = "发动机号") - private String engineNo; + @Schema(description = "外廓长度,毫米") + private Integer outerLength; + + @Schema(description = "外廓宽度,毫米") + private Integer outerWidth; + + @Schema(description = "外廓高度,毫米") + private Integer outerHeight; + + @Schema(description = "核定载质量,KG") + private Integer approvedLoadKg; + + @Schema(description = "准牵引总质量,KG") + private Integer tractionMassKg; + + @Schema(description = "能源类型") + private String energyType; + + @Schema(description = "业务关系:自有 / 管理挂靠 / 业务合作") + private String businessRelation; + + @Schema(description = "强制报废日期") + private String compulsoryScrapDate; + + @Schema(description = "强制报废长期有效:0否 1是") + private Integer compulsoryScrapLongTerm; + + @Schema(description = "行驶证档案编号") + private String drivingLicenseNo; + + @Schema(description = "行驶证主页正面照片") + private String drivingLicenseImage; + + @Schema(description = "行驶证主页反面照片") + private String drivingLicenseMainBack; + + @Schema(description = "行驶证副页正面照片") + private String drivingLicenseViceFront; + + @Schema(description = "行驶证副页反面照片") + private String drivingLicenseViceBack; + + @Schema(description = "行驶证有效期起") + private String drivingLicenseStartDate; @Schema(description = "行驶证有效期止") - private String licenseValidEnd; + private String drivingLicenseEndDate; + + @Schema(description = "行驶证长期有效:0否 1是") + private Integer drivingLicenseLongTerm; + + @Schema(description = "道路运输证号") + private String roadTransportCertNo; + + @Schema(description = "道路运输证照片") + private String roadTransportCertImage; + + @Schema(description = "道路运输证有效期起") + private String roadTransportCertStartDate; + + @Schema(description = "道路运输证有效期止") + private String roadTransportCertEndDate; + + @Schema(description = "道路运输证长期有效:0否 1是") + private Integer roadTransportCertLongTerm; + + @Schema(description = "机动车登记编号") + private String registrationNo; + + @Schema(description = "机动车登记本照片") + private String registrationImage; @Schema(description = "认证状态:0认证中 1认证通过 2认证驳回") private Integer certificationStatus; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java new file mode 100644 index 00000000..c4bf8a40 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java @@ -0,0 +1,100 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.transport.pojo.entity.TransportCertRecord; + +import java.io.Serial; + +/** + * 认证记录视图实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "认证记录") +public class TransportCertRecordVO extends TransportCertRecord { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + /** + * 更新人姓名 + */ + @TableField(exist = false) + @Schema(description = "更新人姓名") + private String updateUserName; + /** + * 提交人姓名 + */ + @TableField(exist = false) + @Schema(description = "提交人姓名") + private String submitUserName; + /** + * 审核人姓名 + */ + @TableField(exist = false) + @Schema(description = "审核人姓名") + private String auditUserName; + /** + * 状态中文文案:审核中 / 已通过 / 已驳回 / 已作废 + *

+ * 由服务层统一翻译后下发,避免小程序、Web、接口三处各造一套说法。 + */ + @TableField(exist = false) + @Schema(description = "状态中文文案") + private String statusText; + /** + * 司机姓名(认证对象档案的**当前**姓名,不是提交时的历史值) + *

+ * 司机侧取认证对象本身;车辆侧取提交该记录的司机。 + * 解析不到档案时为空——存量订正记录里没有账号的那些司机补不出人,如实留空。 + */ + @TableField(exist = false) + @Schema(description = "司机姓名") + private String driverName; + /** + * 司机手机号(认证对象档案的**当前**手机号,明文下发) + *

+ * 审核员靠它联系司机核实材料,故不脱敏;认证快照里从来没有手机号, + * 它只有司机档案这一个来源。 + */ + @TableField(exist = false) + @Schema(description = "司机手机号") + private String driverMobile; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java new file mode 100644 index 00000000..ccdff3cc --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java @@ -0,0 +1,114 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.cache; + +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.cache.utils.CacheUtil; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.SpringUtil; +import org.springblade.core.tool.utils.StringPool; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; + +/** + * 小程序用户缓存 + * + * @author Chill + */ +@Slf4j +public class MpUserCache { + + public static final String MP_USER_CACHE = "blade:mp_user"; + private static final String MP_USER_CACHE_ID = "mp-user:id:"; + + private static IMpUserClient mpUserClient; + + private static IMpUserClient getMpUserClient() { + if (mpUserClient == null) { + mpUserClient = SpringUtil.getBean(IMpUserClient.class); + } + return mpUserClient; + } + + /** + * 按主键获取小程序用户 + */ + public static MpUser getUser(Long userId) { + if (userId == null) { + return null; + } + try { + return CacheUtil.get(MP_USER_CACHE, MP_USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException exception) { + log.warn("小程序用户缓存读取失败,改为直连查询, userId={}", userId, exception); + return loadUserById(userId); + } + } + + /** + * 获取小程序用户真实姓名(优先 realName → name → account → id) + */ + public static String getUserRealName(Long userId) { + if (userId == null) { + return StringPool.EMPTY; + } + try { + MpUser user = getUser(userId); + if (user == null) { + return Func.toStr(userId); + } + if (StringUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StringUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StringUtil.isNotBlank(user.getAccount())) { + return user.getAccount(); + } + return Func.toStr(userId); + } catch (RuntimeException exception) { + log.warn("翻译小程序用户姓名失败,回退为用户ID, userId={}", userId, exception); + return Func.toStr(userId); + } + } + + /** + * 清理小程序用户缓存 + */ + public static void clear() { + CacheUtil.clear(MP_USER_CACHE); + CacheUtil.clear(MP_USER_CACHE, Boolean.FALSE); + } + + private static MpUser loadUserById(Long userId) { + R result = getMpUserClient().userById(userId); + if (result == null || !result.isSuccess()) { + return null; + } + return result.getData(); + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java index 0dbd04fc..b181e1f5 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java @@ -25,6 +25,7 @@ */ package org.springblade.system.cache; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; @@ -42,6 +43,7 @@ import static org.springblade.core.launch.constant.FlowConstant.TASK_USR_PREFIX; * * @author Chill */ +@Slf4j public class UserCache { private static final String USER_CACHE_ID = "user:id:"; private static final String USER_CACHE_ACCOUNT = "user:account:"; @@ -73,10 +75,18 @@ public class UserCache { * @return */ public static User getUser(Long userId) { - return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> { - R result = getUserClient().userInfoById(userId); - return result.getData(); - }); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException exception) { + log.warn("用户缓存读取失败,清理损坏缓存后重试, userId={}", userId, exception); + clearBrokenUserCache(); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException retryException) { + log.warn("用户缓存重试仍失败,改为直连查询, userId={}", userId, retryException); + return loadUserById(userId); + } + } } /** @@ -89,20 +99,25 @@ public class UserCache { if (userId == null) { return StringPool.EMPTY; } - User user = getUser(userId); - if (user == null) { + try { + User user = getUser(userId); + if (user == null) { + return Func.toStr(userId); + } + if (StringUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StringUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StringUtil.isNotBlank(user.getAccount())) { + return user.getAccount(); + } + return Func.toStr(userId); + } catch (RuntimeException exception) { + log.warn("翻译用户姓名失败,回退为用户ID, userId={}", userId, exception); return Func.toStr(userId); } - if (StringUtil.isNotBlank(user.getRealName())) { - return user.getRealName(); - } - if (StringUtil.isNotBlank(user.getName())) { - return user.getName(); - } - if (StringUtil.isNotBlank(user.getAccount())) { - return user.getAccount(); - } - return Func.toStr(userId); } /** @@ -113,10 +128,34 @@ public class UserCache { * @return */ public static User getUser(String tenantId, String account) { - return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, tenantId + StringPool.DASH + account, () -> { - R result = getUserClient().userByAccount(tenantId, account); - return result.getData(); - }); + String accountKey = tenantId + StringPool.DASH + account; + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, accountKey, () -> loadUserByAccount(tenantId, account)); + } catch (RuntimeException exception) { + log.warn("用户缓存读取失败,清理损坏缓存后重试, accountKey={}", accountKey, exception); + clearBrokenUserCache(); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, accountKey, () -> loadUserByAccount(tenantId, account)); + } catch (RuntimeException retryException) { + log.warn("用户缓存重试仍失败,改为直连查询, accountKey={}", accountKey, retryException); + return loadUserByAccount(tenantId, account); + } + } + } + + private static User loadUserById(Long userId) { + R result = getUserClient().userInfoById(userId); + return result.getData(); + } + + private static User loadUserByAccount(String tenantId, String account) { + R result = getUserClient().userByAccount(tenantId, account); + return result.getData(); + } + + private static void clearBrokenUserCache() { + CacheUtil.clear(USER_CACHE); + CacheUtil.clear(USER_CACHE, Boolean.FALSE); } } diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java new file mode 100644 index 00000000..0a63d35c --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java @@ -0,0 +1,74 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.cache; + +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringPool; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.User; + +/** + * 用户展示名解析(系统用户优先,未命中再查小程序用户) + *

+ * 小程序链路写入的审计人/提交人 ID 落在 blade_mp_user,需用本工具翻译姓名。 + * + * @author Chill + */ +public final class UserDisplayCache { + + private UserDisplayCache() { + } + + /** + * 翻译用户姓名:先 blade_user,再 blade_mp_user,都没有则回退 ID + */ + public static String getUserRealName(Long userId) { + if (userId == null) { + return StringPool.EMPTY; + } + User user = UserCache.getUser(userId); + if (user != null && Func.isNotEmpty(user.getId())) { + return resolveName(user.getRealName(), user.getName(), user.getAccount(), userId); + } + MpUser mpUser = MpUserCache.getUser(userId); + if (mpUser != null && Func.isNotEmpty(mpUser.getId())) { + return resolveName(mpUser.getRealName(), mpUser.getName(), mpUser.getAccount(), userId); + } + return Func.toStr(userId); + } + + private static String resolveName(String realName, String name, String account, Long userId) { + if (StringUtil.isNotBlank(realName)) { + return realName; + } + if (StringUtil.isNotBlank(name)) { + return name; + } + if (StringUtil.isNotBlank(account)) { + return account; + } + return Func.toStr(userId); + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java new file mode 100644 index 00000000..0a0a6e4b --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java @@ -0,0 +1,121 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.feign; + +import org.springblade.core.launch.constant.AppConstant; +import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 小程序用户 Feign 接口 + * + * @author Chill + */ +@FeignClient( + value = AppConstant.APPLICATION_SYSTEM_NAME +) +public interface IMpUserClient { + + String API_PREFIX = "/feign/client/mp-user"; + 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_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"; + String SAVE_USER = API_PREFIX + "/save-user"; + String UPDATE_USER = API_PREFIX + "/update-user"; + String BIND_OPENID = API_PREFIX + "/bind-openid"; + String SET_PASSWORD = API_PREFIX + "/set-password"; + + /** + * 按主键查询 + */ + @GetMapping(USER_BY_ID) + R userById(@RequestParam("userId") Long userId); + + /** + * 按租户 + 手机号查询 + */ + @GetMapping(USER_BY_PHONE) + R userByPhone(@RequestParam("tenantId") String tenantId, @RequestParam("phone") String phone); + + /** + * 按租户 + 账号查询 + */ + @GetMapping(USER_BY_ACCOUNT) + R userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + + /** + * 按主键查询登录信息 + */ + @GetMapping(USER_INFO_BY_ID) + R userInfoById(@RequestParam("userId") Long userId); + + /** + * 按租户 + 手机号查询登录信息 + */ + @GetMapping(USER_INFO_BY_PHONE) + R userInfoByPhone(@RequestParam("tenantId") String tenantId, @RequestParam("phone") String phone); + + /** + * 按租户 + 账号查询登录信息 + */ + @GetMapping(USER_INFO_BY_ACCOUNT) + R userInfoByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + + /** + * 新增小程序用户 + */ + @PostMapping(SAVE_USER) + R saveUser(@RequestBody MpUser user); + + /** + * 更新小程序用户 + */ + @PostMapping(UPDATE_USER) + R updateUser(@RequestBody MpUser user); + + /** + * 绑定/更新微信小程序 openid + */ + @PostMapping(BIND_OPENID) + R bindOpenId(@RequestParam("tenantId") String tenantId, + @RequestParam("userId") Long userId, + @RequestParam("openid") String openid); + + /** + * 设置小程序用户密码 + */ + @PostMapping(SET_PASSWORD) + R setPassword(@RequestParam("userId") Long userId, + @RequestParam("password") String password, + @RequestParam("password2") String password2); + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java new file mode 100644 index 00000000..6d613811 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java @@ -0,0 +1,131 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; +import org.springblade.core.tool.jackson.Sensitive; +import org.springblade.core.tool.sensitive.SensitiveType; + +import java.io.Serial; +import java.util.Date; + +/** + * 小程序用户实体(仅用于小程序登录,与 blade_user 分离) + * + * @author Chill + */ +@Data +@TableName("blade_mp_user") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "小程序用户") +public class MpUser extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户编号 + */ + @Schema(description = "用户编号") + private String code; + /** + * 账号 + */ + @Schema(description = "账号") + private String account; + /** + * 密码 + */ + @Schema(description = "密码") + private String password; + /** + * 昵称 + */ + @Schema(description = "昵称") + private String name; + /** + * 真名 + */ + @Schema(description = "真名") + private String realName; + /** + * 头像 + */ + @Schema(description = "头像") + private String avatar; + /** + * 邮箱 + */ + @Sensitive(type = SensitiveType.EMAIL) + @Schema(description = "邮箱") + private String email; + /** + * 手机 + */ + @Sensitive(type = SensitiveType.MOBILE) + @Schema(description = "手机") + private String phone; + /** + * 生日 + */ + @Schema(description = "生日") + private Date birthday; + /** + * 性别 + */ + @Schema(description = "性别") + private Integer sex; + /** + * 微信小程序 openid + */ + @Schema(description = "微信小程序openid") + private String openid; + /** + * 小程序身份,逗号分隔:driver,admin + */ + @Schema(description = "小程序身份") + private String identities; + /** + * 部门id + */ + @Schema(description = "部门id") + private String deptId; + /** + * 岗位id + */ + @Schema(description = "岗位id") + private String postId; + /** + * 备注 + */ + @Schema(description = "备注") + private String remark; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java new file mode 100644 index 00000000..46bfb423 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java @@ -0,0 +1,69 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springblade.core.tool.support.Kv; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 小程序用户登录信息 + * + * @author Chill + */ +@Data +@Schema(description = "小程序用户信息") +public class MpUserInfo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户基础信息 + */ + @Schema(description = "小程序用户") + private MpUser user; + + /** + * 身份编码集合 + */ + @Schema(description = "身份集合") + private List identities; + + /** + * 权限标识集合 + */ + @Schema(description = "权限集合") + private List permissions; + + /** + * 拓展信息 + */ + @Schema(description = "拓展信息") + private Kv detail; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java index 3700a9e0..398ba356 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java @@ -25,6 +25,7 @@ */ package org.springblade.system.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -131,6 +132,11 @@ public class User extends TenantEntity implements IPhone { * 是否是oa的账号 */ private Integer isOa; + /** + * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 + */ + @TableField(updateStrategy = FieldStrategy.NOT_NULL) + private String oaModified; /** * 是否推送mk */ diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java new file mode 100644 index 00000000..12c88b0f --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java @@ -0,0 +1,148 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 小程序用户身份枚举(可叠加,后续可扩展) + * + * @author Chill + */ +@Getter +@AllArgsConstructor +public enum MpUserIdentity { + + /** + * 司机 + */ + DRIVER("driver", "司机"), + + /** + * 管理员(调度端) + */ + ADMIN("admin", "管理员"), + ; + + /** + * 身份编码 + */ + private final String code; + /** + * 身份名称 + */ + private final String label; + + /** + * 管理员身份对应的小程序权限码(兼容原 mp_auth) + */ + public static final String PERMISSION_MP_AUTH = "mp_auth"; + + /** + * detail 中标记小程序用户的 type 值 + */ + public static final String DETAIL_TYPE = "mp"; + + /** + * 解析身份编码 + */ + public static MpUserIdentity of(String code) { + if (Func.isBlank(code)) { + return null; + } + String normalized = code.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(values()) + .filter(item -> item.code.equals(normalized)) + .findFirst() + .orElse(null); + } + + /** + * 拆分身份串为编码列表(去重、保序) + */ + public static List split(String identities) { + if (Func.isBlank(identities)) { + return Collections.emptyList(); + } + Set identitySet = new LinkedHashSet<>(); + for (String part : Func.toStrList(identities)) { + if (Func.isBlank(part)) { + continue; + } + String code = part.trim().toLowerCase(Locale.ROOT); + if (of(code) != null || StringUtil.isNotBlank(code)) { + identitySet.add(code); + } + } + return new ArrayList<>(identitySet); + } + + /** + * 合并身份,确保包含指定身份 + */ + public static String merge(String identities, String... appendCodes) { + Set identitySet = new LinkedHashSet<>(split(identities)); + if (appendCodes != null) { + for (String code : appendCodes) { + if (Func.isNotBlank(code)) { + identitySet.add(code.trim().toLowerCase(Locale.ROOT)); + } + } + } + return identitySet.stream().collect(Collectors.joining(",")); + } + + /** + * 是否包含指定身份 + */ + public static boolean has(String identities, MpUserIdentity identity) { + if (identity == null) { + return false; + } + return split(identities).contains(identity.getCode()); + } + + /** + * 根据身份生成小程序权限码(不再依赖系统角色菜单) + */ + public static List toPermissions(String identities) { + List permissions = new ArrayList<>(); + if (has(identities, ADMIN)) { + permissions.add(PERMISSION_MP_AUTH); + } + return permissions; + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java new file mode 100644 index 00000000..c2ced763 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java @@ -0,0 +1,73 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.system.pojo.entity.MpUser; + +import java.io.Serial; + +/** + * 小程序用户视图实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "小程序用户") +public class MpUserVO extends MpUser { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 身份名称(逗号分隔中文) + */ + @TableField(exist = false) + @Schema(description = "身份名称") + private String identitiesName; + + /** + * 性别名称 + */ + @TableField(exist = false) + @Schema(description = "性别名称") + private String sexName; + + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + + /** + * 更新人姓名 + */ + @TableField(exist = false) + @Schema(description = "更新人姓名") + private String updateUserName; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java new file mode 100644 index 00000000..d2e6482d --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java @@ -0,0 +1,75 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.utils; + +import org.springblade.core.secure.BladeUser; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.support.Kv; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.enums.MpUserIdentity; + +/** + * 小程序登录态识别工具 + * + * @author Chill + */ +public final class MpUserAuth { + + /** + * 严格令牌模式下的占位 ID。 + *

+ * {@code blade.secure.strict-token=true} 时,AuthUtil.userIncomplete 要求 roleId/deptId + * 非空且数值 >= 0;小程序用户不绑定系统角色/部门,用 0 占位避免业务接口 401。 + */ + public static final String PLACEHOLDER_ID = "0"; + + private MpUserAuth() { + } + + /** + * 当前登录用户是否为小程序用户 + */ + public static boolean isMpUser() { + return isMpUser(AuthUtil.getUser()); + } + + /** + * 判断指定登录用户是否为小程序用户 + */ + public static boolean isMpUser(BladeUser bladeUser) { + if (bladeUser == null) { + return false; + } + Kv detail = bladeUser.getDetail(); + if (detail != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(detail.get("type")))) { + return true; + } + // 兼容:无真实系统角色(空或占位 0)且 roleName 为小程序身份 + String roleId = Func.toStr(bladeUser.getRoleId()); + boolean noSystemRole = Func.isBlank(roleId) || PLACEHOLDER_ID.equals(roleId); + return noSystemRole + && Func.isNotBlank(bladeUser.getRoleName()) + && !MpUserIdentity.split(bladeUser.getRoleName()).isEmpty(); + } + +} diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java index 64632b22..07748388 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java @@ -3,6 +3,7 @@ package org.springblade.file.config; import com.aliyun.oss.OSSClientBuilder; import com.obs.services.ObsClient; import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.service.IFileService; @@ -10,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl; import org.springblade.file.service.impl.MinioFileServiceImpl; import org.springblade.file.service.impl.OssFileServiceImpl; import org.springblade.file.service.impl.ObsFileServiceImpl; +import org.springblade.file.util.MinioClientFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,6 +22,7 @@ import org.springframework.core.env.Environment; * @author bfhuange * @date 2024/10/21 */ +@Slf4j @Configuration public class FileConfig { @@ -63,10 +66,22 @@ public class FileConfig { private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) { validateStoreProperties(STORAGE_MINIO, properties); - MinioClient minioClient = MinioClient.builder() - .endpoint(properties.getEndpoint()) - .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) - .build(); + String endpointOrigin = MinioClientFactory.resolveOrigin(properties.getEndpoint()); + String endpointPathPrefix = MinioClientFactory.resolvePathPrefix(properties.getEndpoint()); + if (StringUtil.isNotBlank(endpointPathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址,transform-endpoint 使用外网 HTTPS", + endpointOrigin, endpointPathPrefix); + } + if (StringUtil.isBlank(properties.getTransformEndpoint()) + && properties.getEndpoint().trim().toLowerCase().startsWith("http://")) { + log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传", + properties.getEndpoint()); + } else { + log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}", + endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint()); + } + MinioClient minioClient = MinioClientFactory.create( + properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret()); return new MinioFileServiceImpl(properties, minioClient); } @@ -95,7 +110,16 @@ public class FileConfig { FileStorageProperties.StoreProperties mergedProperties = new FileStorageProperties.StoreProperties(); String fileStoragePrefix = "file.storage." + prefix; mergedProperties.setEndpoint(firstNotBlank(properties.getEndpoint(), environment.getProperty(fileStoragePrefix + ".endpoint"), environment.getProperty(prefix + ".endpoint"))); - mergedProperties.setTransformEndpoint(firstNotBlank(properties.getTransformEndpoint(), environment.getProperty(fileStoragePrefix + ".transform-endpoint"), environment.getProperty(prefix + ".transform-endpoint"))); + // 资源管理页 /resource/oss 的外网地址写在库表,不进 Spring Environment;凭证分片必须单独配 file.storage.minio.transform-endpoint + mergedProperties.setTransformEndpoint(firstNotBlank( + properties.getTransformEndpoint(), + environment.getProperty(fileStoragePrefix + ".transform-endpoint"), + environment.getProperty(fileStoragePrefix + ".transformEndpoint"), + environment.getProperty(prefix + ".transform-endpoint"), + environment.getProperty(prefix + ".transformEndpoint"), + environment.getProperty("oss.transform-endpoint"), + environment.getProperty("oss.transformEndpoint") + )); mergedProperties.setAccessKeyId(firstNotBlank(properties.getAccessKeyId(), environment.getProperty(fileStoragePrefix + ".access-key-id"), environment.getProperty(fileStoragePrefix + ".access-key"), environment.getProperty(prefix + ".access-key-id"), environment.getProperty(prefix + ".access-key"))); mergedProperties.setAccessKeySecret(firstNotBlank(properties.getAccessKeySecret(), environment.getProperty(fileStoragePrefix + ".access-key-secret"), environment.getProperty(fileStoragePrefix + ".secret-key"), environment.getProperty(prefix + ".access-key-secret"), environment.getProperty(prefix + ".secret-key"))); mergedProperties.setBucketName(firstNotBlank(properties.getBucketName(), environment.getProperty(fileStoragePrefix + ".bucket-name"), environment.getProperty(prefix + ".bucket-name"))); @@ -107,7 +131,7 @@ public class FileConfig { private String firstNotBlank(String... values) { for (String value : values) { if (StringUtil.isNotBlank(value)) { - return value; + return value.trim(); } } return null; diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java index 62e4dabf..462de6e0 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java @@ -111,4 +111,26 @@ public class FileTaskController extends BladeController { String uploadUrl = fileTaskService.getUploadUrl(id, partNumber); return R.data(uploadUrl); } + + @ApiLog("上传任务-服务端代传分片") + @PostMapping(value = "/uploadPart", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Parameters({ + @Parameter(name = "id", description = "文件任务id", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "partNumber", description = "分段编号", in = ParameterIn.QUERY, schema = @Schema(type = "int")) + }) + @Operation(summary = "服务端代传分片", description = "HTTPS 页面无法直连 HTTP MinIO 时,由服务端转发分片到对象存储") + public R uploadPart(Long id, Integer partNumber, org.springframework.web.multipart.MultipartFile file) throws java.io.IOException { + if (file == null || file.isEmpty()) { + return R.fail("分片内容不能为空"); + } + java.io.File tempFile = java.io.File.createTempFile("file-part-", ".bin"); + try { + file.transferTo(tempFile); + return R.data(fileTaskService.uploadPart(id, partNumber, tempFile)); + } finally { + if (tempFile.exists() && !tempFile.delete()) { + tempFile.deleteOnExit(); + } + } + } } diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java index ae394521..f47120a4 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java @@ -74,6 +74,16 @@ public interface IFileTaskService extends IService { */ String getUploadUrl(Long id, Integer partNumber); + /** + * 服务端代传分片(HTTPS 页面无法直连 HTTP MinIO 时使用) + * + * @param id 文件任务id + * @param partNumber 分段编号 + * @param partFile 分片临时文件 + * @return ETag + */ + String uploadPart(Long id, Integer partNumber, java.io.File partFile); + /** * 取消文件任务 * @param taskIds diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java index b92fc66e..25a46bab 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java @@ -259,6 +259,33 @@ public class FileTaskServiceImpl extends ServiceImpl i return fileService.getUploadPartUrl(fileTask.getObjectKey(), fileTask.getUploadId(), partNumber, null); } + @Override + public String uploadPart(Long id, Integer partNumber, java.io.File partFile) { + FileTask fileTask = this.getById(id); + Func.requireNotNull(fileTask, "文件任务不存在"); + if (FileTaskStatus.COMPLETED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已完成"); + } + if (FileTaskStatus.PAUSED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已暂停"); + } + if (partFile == null || !partFile.exists()) { + throw new ServiceException("分片文件不存在"); + } + org.springblade.file.pojo.vo.PartEtagVO partEtag = fileService.uploadPart( + fileTask.getObjectKey(), + fileTask.getUploadId(), + partFile, + partNumber, + 0L, + partFile.length() + ); + if (partEtag == null || StringUtil.isBlank(partEtag.getEtag())) { + throw new ServiceException("分片上传未返回 ETag"); + } + return partEtag.getEtag().replace("\"", ""); + } + @Transactional(rollbackFor = Exception.class) @Override public void cancelBatchByIds(List taskIds) { diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index 5214c628..c7d2ed4e 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -25,11 +25,12 @@ */ package org.springblade.file.service.impl; +import io.minio.ComposeObjectArgs; +import io.minio.ComposeSource; import io.minio.GetObjectArgs; import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; -import io.minio.ComposeObjectArgs; -import io.minio.ComposeSource; +import io.minio.ObjectWriteResponse; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; import io.minio.UploadObjectArgs; @@ -39,9 +40,13 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.config.FileStorageProperties; +import org.springblade.file.util.MinioClientFactory; +import org.springblade.file.pojo.vo.PartEtagVO; import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -61,9 +66,11 @@ import java.util.UUID; public class MinioFileServiceImpl extends AbstractFileService { private static final long PART_SIZE = 10 * 1024 * 1024L; + private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION; private final FileStorageProperties.StoreProperties properties; private final MinioClient minioClient; + private volatile MinioClient transformPresignClient; @Override public String initiateMultipartUpload(String objectKey) { @@ -73,15 +80,50 @@ public class MinioFileServiceImpl extends AbstractFileService { @Override public String getUploadPartUrl(String objectKey, String uploadId, Integer partNumber, Long expires) { try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() - .method(Method.PUT).bucket(properties.getBucketName()).object(partObjectKey(objectKey, uploadId, partNumber)) - .expiry(Math.toIntExact(resolveExpires(expires))).build()); + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + .method(Method.PUT) + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .region(DEFAULT_REGION) + .expiry(Math.toIntExact(resolveExpires(expires))) + .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取分片上传链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("获取分片上传链接异常"); } } + @Override + public PartEtagVO uploadPart(String objectKey, String uploadId, File file, int partNumber, Long offset, Long partSize) { + long start = offset == null ? 0L : offset; + long size = partSize == null ? Math.max(0L, file.length() - start) : partSize; + try (FileInputStream inputStream = new FileInputStream(file)) { + long skipped = inputStream.skip(start); + if (skipped < start) { + throw new ServiceException("MinIO分段上传读取文件偏移失败"); + } + ObjectWriteResponse response = minioClient.putObject(PutObjectArgs.builder() + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .stream(inputStream, size, PART_SIZE) + .build()); + String etag = response.etag() == null ? "" : response.etag().replace("\"", ""); + return new PartEtagVO(etag, partNumber); + } catch (ServiceException serviceException) { + throw serviceException; + } catch (Exception exception) { + log.error("MinIO分段上传异常 objectKey:{} partNumber:{}", objectKey, partNumber, exception); + throw new ServiceException("MinIO分段上传异常"); + } + } + + @Override + public void cancelMultipartUpload(String objectKey, String uploadId) { + // MinIO 分片以独立对象模拟,取消时由任务层清理业务状态即可 + log.debug("MinIO取消分段上传 objectKey:{} uploadId:{}", objectKey, uploadId); + } + @Override public void completeMultipartUpload(String objectKey, String uploadId, List partEtags) { try { @@ -143,12 +185,14 @@ public class MinioFileServiceImpl extends AbstractFileService { return null; } try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(properties.getBucketName()) .object(resolveObjectKey(objectKey)) + .region(DEFAULT_REGION) .expiry(Math.toIntExact(resolveExpires(expires))) .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取文件链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("MinIO获取文件链接异常"); @@ -170,6 +214,62 @@ public class MinioFileServiceImpl extends AbstractFileService { } } + /** + * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 + * MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 + */ + private MinioClient resolvePresignClient() { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint)) { + return minioClient; + } + MinioClient cachedClient = transformPresignClient; + if (cachedClient != null) { + return cachedClient; + } + synchronized (this) { + if (transformPresignClient == null) { + transformPresignClient = MinioClientFactory.create( + transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret()); + } + return transformPresignClient; + } + } + + private String applyTransformPathPrefix(String presignedUrl) { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) { + return presignedUrl; + } + String pathPrefix = normalizePathPrefix(URI.create(transformEndpoint.trim()).getPath()); + if (StringUtil.isBlank(pathPrefix)) { + return presignedUrl; + } + URI url = URI.create(presignedUrl); + String path = url.getRawPath() == null ? "" : url.getRawPath(); + if (path.equals(pathPrefix) || path.startsWith(pathPrefix + "/")) { + return presignedUrl; + } + StringBuilder result = new StringBuilder(); + result.append(url.getScheme()).append("://").append(url.getHost()); + if (url.getPort() > 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (StringUtil.isNotBlank(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String resolveObjectKey(String objectKey) { if (StringUtil.isBlank(properties.getRootDirectory())) { return objectKey; diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java new file mode 100644 index 00000000..384dc61e --- /dev/null +++ b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java @@ -0,0 +1,121 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.file.util; + +import io.minio.MinioClient; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * MinIO 客户端构建。 + *

+ * SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则: + *

    + *
  • endpoint 使用 origin
  • + *
  • 通过 OkHttp 拦截器把 /oss 拼回每个 API 请求
  • + *
  • 固定 region,避免 GetBucketLocation 打到业务网关拿到 HTML
  • + *
+ */ +public final class MinioClientFactory { + + public static final String DEFAULT_REGION = "us-east-1"; + + private MinioClientFactory() { + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + EndpointParts parts = parseEndpoint(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(parts.origin()) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(parts.pathPrefix())) { + builder.httpClient(buildHttpClient(parts.pathPrefix()), true); + } + return builder.build(); + } + + public static String resolveOrigin(String endpoint) { + return parseEndpoint(endpoint).origin(); + } + + public static String resolvePathPrefix(String endpoint) { + return parseEndpoint(endpoint).pathPrefix(); + } + + private static EndpointParts parseEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return new EndpointParts(origin.toString(), normalizePathPrefix(uri.getPath())); + } + + private static String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); + } + + private record EndpointParts(String origin, String pathPrefix) { + } +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java index 67dbf494..39d60f72 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java @@ -93,7 +93,8 @@ public class DeptController extends BladeController { @PreAuth(menu = "dept") @GetMapping("/list") @Parameters({ - @Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 2) @@ -108,7 +109,8 @@ public class DeptController extends BladeController { @PreAuth(menu = "dept") @GetMapping("/lazy-list") @Parameters({ - @Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 3) @@ -195,8 +197,10 @@ public class DeptController extends BladeController { @Operation(summary = "同步OA公司") public R syncOaCompany( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncCompanyPage(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncCompanyPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** @@ -208,8 +212,10 @@ public class DeptController extends BladeController { @Operation(summary = "同步OA部门") public R syncOaDepartment( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncDepartmentPage(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java new file mode 100644 index 00000000..b4c84f8e --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java @@ -0,0 +1,134 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +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.tenant.annotation.NonDS; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.vo.MpUserVO; +import org.springblade.system.service.IMpUserService; +import org.springblade.system.wrapper.MpUserWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 小程序用户管理 控制器 + * + * @author Chill + */ +@NonDS +@RestController +@AllArgsConstructor +@PreAuth(menu = "mp_user") +@RequestMapping("mp-user") +@Tag(name = "小程序用户", description = "小程序用户管理") +public class MpUserController extends BladeController { + + private final IMpUserService mpUserService; + + /** + * 详情 + */ + @GetMapping("/detail") + @ApiOperationSupport(order = 1) + @Operation(summary = "详情", description = "传入id") + public R detail(MpUser mpUser) { + MpUser detail = mpUserService.getOne(Condition.getQueryWrapper(mpUser)); + if (detail == null) { + return R.fail("小程序用户不存在"); + } + return R.data(MpUserWrapper.build().entityVO(detail)); + } + + /** + * 分页 + */ + @GetMapping("/list") + @ApiOperationSupport(order = 2) + @Operation(summary = "分页", description = "传入查询条件") + public R> list(MpUser mpUser, Query query) { + IPage pages = mpUserService.selectMpUserPage(Condition.getPage(query), mpUser); + return R.data(MpUserWrapper.build().pageVO(pages)); + } + + /** + * 新增或修改 + */ + @PostMapping("/submit") + @ApiOperationSupport(order = 3) + @Operation(summary = "新增或修改", description = "传入MpUser") + public R submit(@Valid @RequestBody MpUser mpUser) { + return R.status(mpUserService.submitOrUpdate(mpUser)); + } + + /** + * 逻辑删除 + */ + @PostMapping("/remove") + @ApiOperationSupport(order = 4) + @Operation(summary = "逻辑删除", description = "传入ids") + public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) { + return R.status(mpUserService.removeMpUser(Func.toLongList(ids))); + } + + /** + * 启用 / 停用 + */ + @PostMapping("/change-status") + @ApiOperationSupport(order = 5) + @Operation(summary = "修改状态", description = "status:1启用,0停用") + public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) { + return R.status(mpUserService.changeStatus(id, status)); + } + + /** + * 重置密码 + */ + @PostMapping("/reset-password") + @ApiOperationSupport(order = 6) + @Operation(summary = "重置密码", description = "传入userId集合,返回账号与明文密码") + public R> resetPassword( + @Parameter(description = "userId集合", required = true) @RequestParam String userIds) { + return R.data(mpUserService.resetPassword(Func.toLongList(userIds))); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java index ab5e7820..34a468ad 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java @@ -53,11 +53,14 @@ import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.StringPool; import org.springblade.system.excel.UserExcel; import org.springblade.system.excel.UserImporter; +import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.vo.OaPersonSyncPageVO; import org.springblade.system.pojo.vo.UserVO; import org.springblade.system.service.IOASyncService; +import org.springblade.system.service.IMpUserService; import org.springblade.system.service.IUserService; +import org.springblade.system.utils.MpUserAuth; import org.springblade.system.wrapper.UserWrapper; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -79,6 +82,7 @@ import java.util.Map; public class UserController { private final IUserService userService; + private final IMpUserService mpUserService; private final IOASyncService oaSyncService; /** @@ -101,6 +105,10 @@ public class UserController { @Operation(summary = "查看详情", description = "传入id") @GetMapping("/info") public R info(BladeUser user) { + if (MpUserAuth.isMpUser(user)) { + MpUser mpUser = mpUserService.getById(user.getUserId()); + return R.data(toMpUserVO(mpUser)); + } User detail = userService.getById(user.getUserId()); return R.data(UserWrapper.build().entityVO(detail)); } @@ -112,11 +120,12 @@ public class UserController { @GetMapping("/list") @BladeView(Views.Admin.class) @Parameters({ - @Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 3) - @Operation(summary = "列表", description = "传入account和realName") + @Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索") public R> list(@Parameter(hidden = true) @RequestParam Map user, Query query) { return R.data(userService.selectPage(user, query)); } @@ -128,11 +137,12 @@ public class UserController { @GetMapping("/page") @BladeView(Views.Admin.class) @Parameters({ - @Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 4) - @Operation(summary = "列表", description = "传入account和realName") + @Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索") public R> page(@Parameter(hidden = true) User user, Query query, Long deptId, BladeUser bladeUser) { IPage pages = userService.selectUserPage(Condition.getPage(query), user, deptId, (AuthUtil.isAdministrator() ? StringPool.EMPTY : bladeUser.getTenantId())); return R.data(UserWrapper.build().pageVO(pages)); @@ -169,8 +179,10 @@ public class UserController { @Operation(summary = "同步OA人员") public R syncIamAccounts( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncPersonFromUserList(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncPersonFromUserList(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** @@ -184,6 +196,18 @@ public class UserController { return R.status(userService.updateUser(user)); } + /** + * 启用或停用 + */ + @IsAdmin + @PostMapping("/change-status") + @ApiOperationSupport(order = 6) + @Operation(summary = "启用停用", description = "传入id和status") + public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) { + return R.status(userService.changeUserStatus(id, status)); + } + /** * 删除 */ @@ -236,6 +260,7 @@ public class UserController { * 当前用户设置/重置登录密码(小程序首次设密、短信验证后改密) *

* 对外路径:/blade-system/user/password ;网关别名 /blade-user/password 亦可到达。 + * 小程序用户写入 blade_mp_user,系统用户仍写入 blade_user。 */ @PostMapping("/password") @ApiOperationSupport(order = 10) @@ -243,6 +268,9 @@ public class UserController { public R password(BladeUser user, @Parameter(description = "新密码", required = true) @RequestParam String password, @Parameter(description = "确认密码", required = true) @RequestParam String password2) { + if (MpUserAuth.isMpUser(user)) { + return R.status(mpUserService.setPassword(user.getUserId(), password, password2)); + } return R.status(userService.setPassword(user.getUserId(), password, password2)); } @@ -422,4 +450,34 @@ public class UserController { return R.data(list); } + /** + * 小程序用户转 UserVO(兼容原 /user/info 响应结构) + */ + private UserVO toMpUserVO(MpUser mpUser) { + if (mpUser == null) { + return null; + } + UserVO userVO = new UserVO(); + userVO.setId(mpUser.getId()); + userVO.setTenantId(mpUser.getTenantId()); + userVO.setCode(mpUser.getCode()); + userVO.setAccount(mpUser.getAccount()); + userVO.setName(mpUser.getName()); + userVO.setRealName(mpUser.getRealName()); + userVO.setAvatar(mpUser.getAvatar()); + userVO.setEmail(mpUser.getEmail()); + userVO.setPhone(mpUser.getPhone()); + userVO.setBirthday(mpUser.getBirthday()); + userVO.setSex(mpUser.getSex()); + userVO.setDeptId(mpUser.getDeptId()); + userVO.setPostId(mpUser.getPostId()); + userVO.setRemark(mpUser.getRemark()); + userVO.setStatus(mpUser.getStatus()); + userVO.setCreateTime(mpUser.getCreateTime()); + userVO.setUpdateTime(mpUser.getUpdateTime()); + // 身份放入 roleName,便于前端兼容展示 + userVO.setRoleName(mpUser.getIdentities()); + return userVO; + } + } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java index ecf7b715..cae8c860 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java @@ -43,7 +43,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * 用户手机号变更(小程序「修改手机号」) + * 小程序用户手机号变更(写入 blade_mp_user) *

* 对外路径:/blade-system/user/phone/** ;网关别名 /blade-user/phone/** 亦可到达。 * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java index d46b3c82..ba5bdf43 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java @@ -44,92 +44,79 @@ public interface DeptConvert { @Mapping(source = "subcompanyname", target = "deptName") @Mapping(source = "subcompanydesc", target = "fullName") - // 父公司id转为父部门编码,要拼接前缀 @Mapping(source = "supsubcomid", target = "parentCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - @Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss") + @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); @Mapping(source = "departmentname", target = "deptName") @Mapping(source = "departmentmark", target = "fullName") - // 父部门id转为父部门编码,要拼接前缀 @Mapping(source = "supdepid", target = "parentCode") - // 所属子公司id转为所属公司编码,要拼接前缀 @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - @Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss") + @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OADepartmentResponse department); /** - * oa公司转本系统部门 - * @param company - * @return + * oa公司转本系统部门。组织编码由同步服务按 GWWL+三位数字 统一分配。 */ default Dept company2dept(OACompanyResponse company) { Dept dept = this.baseConvert(company); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.COMPANY_OA_PREFIX + company.getId()); + // 编码稍后按规则生成;此处仅落 OA 主键,供按 oaId 匹配与重建层级 + dept.setDeptCode(null); String parentCode = dept.getParentCode(); if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.COMPANY_OA_PREFIX + parentCode); - // 父部门id后续处理 + // 非顶级:parentCode 暂存上级 OA 主键,分配编码后再替换为正式组织编码 + dept.setParentCode(parentCode); + dept.setParentId(null); } else { - // 父部门编码是0,父id也设置为0 - dept.setParentId(OAConvertConstant.ROOT_PARENT_ID); + dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); } - // 公司所属公司编码就是他自己的部门编码 - dept.setBelongCompanyCode(dept.getDeptCode()); - // OA主键与上级OA主键,分批同步结束后用于重建层级 + dept.setBelongCompanyCode(null); dept.setOaId(company.getId()); dept.setOaSupSubComId(company.getSupsubcomid()); - // 显示顺序处理 dept.setSort(OAUtils.parseInt(company.getShoworder())); - // 部门类型,公司 dept.setDeptCategory(DeptCategory.COMPANY.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } /** - * oa部门转本系统部门 - * @param oaDept - * @param companyDeptMap 所有公司的部门编码与id map - * @return + * oa部门转本系统部门。组织编码由同步服务按「所属公司编码+三位数字」统一分配。 + * + * @param oaDept OA部门 + * @param companyByOaId 已同步公司(oaId → 本地公司) */ - default Dept dept2dept(OADepartmentResponse oaDept, Map companyDeptMap) { + default Dept dept2dept(OADepartmentResponse oaDept, Map companyByOaId) { Dept dept = this.baseConvert(oaDept); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId()); - // OA主键;上级为根部门时记录所属公司OA主键,否则记录上级部门OA主键 + dept.setDeptCode(null); dept.setOaId(oaDept.getId()); if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { dept.setOaSupSubComId(oaDept.getSubcompanyid1()); } else { dept.setOaSupSubComId(oaDept.getSupdepid()); } - // 部门所属公司编码就是他所属公司编码加前缀 - dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode()); - String parentCode = dept.getParentCode(); - if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + parentCode); - // 父部门id后续处理 + Dept belongCompany = companyByOaId == null ? null : companyByOaId.get(oaDept.getSubcompanyid1()); + if (belongCompany != null) { + dept.setBelongCompanyCode(belongCompany.getDeptCode()); + dept.setParentId(null); + if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + } else { + // 上级为部门:parentCode 暂存上级部门 OA 主键,重建层级时再解析 + dept.setParentCode(oaDept.getSupdepid()); + } } else { - // 父部门编码是0,设置为公司编码 - dept.setParentCode(dept.getBelongCompanyCode()); - // 根据父部门编码获取对应的id - dept.setParentId(companyDeptMap.get(dept.getParentCode())); + // 所属公司尚未解析时,暂存 OA 公司主键,后续补齐正式编码 + dept.setBelongCompanyCode(oaDept.getSubcompanyid1()); + dept.setParentCode(oaDept.getSupdepid()); } - - // 显示顺序处理 dept.setSort(OAUtils.parseInt(oaDept.getShoworder())); - // 部门类型,部门 dept.setDeptCategory(DeptCategory.DEPT.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java index 28f7004b..6decb305 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -70,6 +70,7 @@ public interface UserConvert { @Mapping(source = "lastname", target = "realName") @Mapping(source = "mobile", target = "phone") @Mapping(source = "email", target = "email") + @Mapping(source = "modified", target = "oaModified") User baseConvert(OAPersonResponse person); @Mapping(source = "subcompanyname", target = "companyName") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java index 3da00ca2..a7130e25 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java @@ -54,7 +54,7 @@ public class RailwayStationExcel implements Serializable { @ExcelProperty("TMIS国标编码*") private String tmisCode; - @ExcelProperty("电报略码*") + @ExcelProperty("电报略码") private String telegraphCode; @ExcelProperty("车站名称*") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java index ddaf8230..424cc0c9 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java @@ -56,7 +56,7 @@ public class RailwayStationExportExcel implements Serializable { @ExcelProperty("TMIS国标编码*") private String tmisCode; - @ExcelProperty("电报略码*") + @ExcelProperty("电报略码") private String telegraphCode; @ExcelProperty("车站名称*") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java new file mode 100644 index 00000000..4d8eabfe --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java @@ -0,0 +1,110 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.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.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.service.IMpUserService; +import org.springframework.web.bind.annotation.GetMapping; +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 MpUserClient implements IMpUserClient { + + private final IMpUserService mpUserService; + + @Override + @GetMapping(USER_BY_ID) + public R userById(Long userId) { + return R.data(mpUserService.getById(userId)); + } + + @Override + @GetMapping(USER_BY_PHONE) + public R userByPhone(String tenantId, String phone) { + return R.data(mpUserService.getByPhone(tenantId, phone)); + } + + @Override + @GetMapping(USER_BY_ACCOUNT) + public R userByAccount(String tenantId, String account) { + return R.data(mpUserService.getByAccount(tenantId, account)); + } + + @Override + @GetMapping(USER_INFO_BY_ID) + public R userInfoById(Long userId) { + return R.data(mpUserService.userInfo(userId)); + } + + @Override + @GetMapping(USER_INFO_BY_PHONE) + public R userInfoByPhone(String tenantId, String phone) { + return R.data(mpUserService.userInfoByPhone(tenantId, phone)); + } + + @Override + @GetMapping(USER_INFO_BY_ACCOUNT) + public R userInfoByAccount(String tenantId, String account) { + return R.data(mpUserService.userInfoByAccount(tenantId, account)); + } + + @Override + @PostMapping(SAVE_USER) + public R saveUser(@RequestBody MpUser user) { + return R.data(mpUserService.submit(user)); + } + + @Override + @PostMapping(UPDATE_USER) + public R updateUser(@RequestBody MpUser user) { + return R.data(mpUserService.updateUser(user)); + } + + @Override + @PostMapping(BIND_OPENID) + public R bindOpenId(String tenantId, Long userId, String openid) { + return R.data(mpUserService.bindOpenId(tenantId, userId, openid)); + } + + @Override + @PostMapping(SET_PASSWORD) + public R setPassword(Long userId, String password, String password2) { + return R.data(mpUserService.setPassword(userId, password, password2)); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml index e125b43b..f45626dd 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml @@ -13,6 +13,7 @@ + @@ -34,6 +35,7 @@ + @@ -78,6 +80,9 @@ and dept.dept_name like concat(concat('%', #{param3.deptName}),'%') + + and dept.short_name like concat(concat('%', #{param3.shortName}),'%') + and dept.full_name like concat(concat('%', #{param3.fullName}),'%') diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MpUserMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MpUserMapper.java new file mode 100644 index 00000000..f6e53d79 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MpUserMapper.java @@ -0,0 +1,34 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.springblade.system.pojo.entity.MpUser; + +/** + * 小程序用户 Mapper + * + * @author Chill + */ +public interface MpUserMapper extends BaseMapper { +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java index f06997f6..69c8c24e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java @@ -45,6 +45,11 @@ public interface RailwayStationMapper extends BaseMapper { */ RailwayStation selectByCodeIncludingDeleted(@Param("code") String code); + /** + * 按 TMIS 国标编码查询铁路车站(包含逻辑删除记录,用于导入恢复)。 + */ + RailwayStation selectByTmisCodeIncludingDeleted(@Param("tmisCode") String tmisCode); + /** * 恢复逻辑删除铁路车站。 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml index 54d82bde..72d99a14 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml @@ -36,6 +36,14 @@ SELECT * FROM blade_railway_station WHERE code = #{code} + LIMIT 1 + + + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml index 70dd2e9a..23fff0b7 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml @@ -44,13 +44,16 @@ and bu.tenant_id = #{user.tenantId} - and bu.account = #{user.account} + and bu.account like concat('%', #{user.account}, '%') - and bu.real_name = #{user.realName} + and ( + bu.real_name like concat('%', #{user.realName}, '%') + or bu.name like concat('%', #{user.realName}, '%') + ) - and bu.phone = #{user.phone} + and bu.phone like concat('%', #{user.phone}, '%') and bu.user_type = #{user.userType} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java b/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java index ba893bfd..07a0ca8f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java @@ -30,6 +30,7 @@ import org.springblade.core.literule.core.RuleComponent; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.Tenant; +import org.springblade.system.pojo.enums.DeptCategory; import org.springblade.system.rule.context.TenantContext; import static org.springblade.system.rule.constant.TenantRuleConstant.TENANT_DEPT_RULE; @@ -54,7 +55,7 @@ public class TenantDeptRule extends RuleComponent { dept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); dept.setDeptName(tenant.getTenantName()); dept.setFullName(tenant.getTenantName()); - dept.setDeptCategory(1); + dept.setDeptCategory(DeptCategory.GROUP.getCode()); dept.setSort(2); dept.setIsDeleted(BladeConstant.DB_NOT_DELETED); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java index cee1baf1..b2afe5f0 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java @@ -149,12 +149,19 @@ public interface IDeptService extends IService { boolean removeDept(String ids); /** - * 删除非顶级组织,顶级组织(父级为 0)保留 + * 清除非保留顶级外的组织;保留 id=135 集团锚点,以及名称为「外部组织」的顶级 * * @return 删除数量 */ int clearNonTopDepts(); + /** + * 确保双顶级:id=135 集团(编码 GWWL)+ 独立「外部组织」顶级 + * + * @return 集团顶级组织(id=135) + */ + Dept ensureOaSyncRootDept(); + /** * 新增或修改部门 *

diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java new file mode 100644 index 00000000..c0294ced --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java @@ -0,0 +1,110 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.springblade.core.mp.base.BaseService; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; + +import java.util.List; +import java.util.Map; + +/** + * 小程序用户 服务类 + * + * @author Chill + */ +public interface IMpUserService extends BaseService { + + /** + * 按租户 + 手机号查询 + */ + MpUser getByPhone(String tenantId, String phone); + + /** + * 按租户 + 账号查询 + */ + MpUser getByAccount(String tenantId, String account); + + /** + * 按主键组装登录信息 + */ + MpUserInfo userInfo(Long userId); + + /** + * 按租户 + 手机号组装登录信息 + */ + MpUserInfo userInfoByPhone(String tenantId, String phone); + + /** + * 按租户 + 账号组装登录信息 + */ + MpUserInfo userInfoByAccount(String tenantId, String account); + + /** + * 后台分页查询 + */ + IPage selectMpUserPage(IPage page, MpUser query); + + /** + * 保存小程序用户 + */ + boolean submit(MpUser user); + + /** + * 更新小程序用户 + */ + boolean updateUser(MpUser user); + + /** + * 新增或修改(后台管理) + */ + boolean submitOrUpdate(MpUser user); + + /** + * 逻辑删除 + */ + boolean removeMpUser(List ids); + + /** + * 启用 / 停用 + */ + boolean changeStatus(Long id, Integer status); + + /** + * 重置密码为随机密码,返回 account -> 明文密码 + */ + Map resetPassword(List userIds); + + /** + * 绑定/更新 openid + */ + boolean bindOpenId(String tenantId, Long userId, String openid); + + /** + * 当前小程序用户设置密码 + */ + boolean setPassword(Long userId, String password, String password2); + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java index 3e11ad4b..3b003897 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java @@ -32,27 +32,33 @@ public interface IOASyncService { /** * 按页从 OA 人员接口同步组织与人员 * - * @param current 当前页,从 1 开始 - * @param size 每页条数 + * @param current 当前页,从 1 开始 + * @param size 每页条数 + * @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA) + * @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值 * @return 本页同步结果 */ - OaPersonSyncPageVO syncPersonFromUserList(int current, int size); + OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince); /** * 按页从 OA 公司接口同步公司 * - * @param current 当前页,从 1 开始 - * @param size 每页条数 + * @param current 当前页,从 1 开始 + * @param size 每页条数 + * @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA) + * @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值 * @return 本页同步结果 */ - OaOrgSyncPageVO syncCompanyPage(int current, int size); + OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince); /** * 按页从 OA 部门接口同步部门;最后一页完成后更新祖级列表 * - * @param current 当前页,从 1 开始 - * @param size 每页条数 + * @param current 当前页,从 1 开始 + * @param size 每页条数 + * @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA) + * @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值 * @return 本页同步结果 */ - OaOrgSyncPageVO syncDepartmentPage(int current, int size); + OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java index 7c3db61f..fedecc77 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java @@ -93,6 +93,15 @@ public interface IUserService extends BaseService { */ boolean updateUser(User user); + /** + * 启用或停用用户(租户守卫校验用户归属,仅更新状态) + * + * @param userId 用户主键 + * @param status 状态:1 启用,0 停用 + * @return 是否成功 + */ + boolean changeUserStatus(Long userId, Integer status); + /** * 修改用户基本信息(仅限当前登录用户自身,忽略角色 / 部门 / 岗位 / 账号等敏感字段) * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index a07ec19c..c0a7c966 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -27,6 +27,7 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.JsonNode; @@ -389,16 +390,193 @@ public class DeptServiceImpl extends ServiceImpl implements ID @Override @Transactional(rollbackFor = Exception.class) public int clearNonTopDepts() { - Long nonTopCount = this.count(Wrappers.lambdaQuery() - .isNotNull(Dept::getParentId) - .ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID)); - if (nonTopCount == null || nonTopCount == 0L) { + // 同步前清理:保留集团锚点 id=135,以及「外部组织」顶级;其余删除 + Dept groupRoot = this.ensureOaSyncRootDept(); + Dept externalRoot = this.ensureExternalOrgTopDept(); + Set keepIds = new HashSet<>(); + if (groupRoot != null && groupRoot.getId() != null) { + keepIds.add(groupRoot.getId()); + } + if (externalRoot != null && externalRoot.getId() != null) { + keepIds.add(externalRoot.getId()); + } + if (keepIds.isEmpty()) { return 0; } - this.remove(Wrappers.lambdaQuery() - .isNotNull(Dept::getParentId) - .ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID)); - return nonTopCount.intValue(); + Long clearCount = this.count(Wrappers.lambdaQuery().notIn(Dept::getId, keepIds)); + if (clearCount == null || clearCount == 0L) { + return 0; + } + this.remove(Wrappers.lambdaQuery().notIn(Dept::getId, keepIds)); + return clearCount.intValue(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Dept ensureOaSyncRootDept() { + this.migrateLegacyOaDeptCategory(); + this.removeLegacyOaSyncRoots(); + Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + if (rootDept == null) { + rootDept = new Dept(); + rootDept.setId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + rootDept.setTenantId(tenantId); + rootDept.setParentId(BladeConstant.TOP_PARENT_ID); + rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setDeptCategory(DeptCategory.GROUP.getCode()); + rootDept.setSort(0); + rootDept.setStatus(DataStatusEnum.ENABLE.getCode()); + rootDept.setIsDeleted(BladeConstant.DB_NOT_DELETED); + rootDept.setIsOa(0); + rootDept.setIsPlatformCompany(0); + rootDept.setSyncTime(new Date()); + this.save(rootDept); + log.info("已创建 OA 同步集团顶级 id={} name={} code={}", + OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, + OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME, + OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + } else { + boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName()); + boolean needUpdate = wronglyNamedAsExternal + || !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode()) + || !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory()) + || !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId()); + if (needUpdate) { + Dept updateParam = new Dept(); + updateParam.setId(rootDept.getId()); + if (wronglyNamedAsExternal || StringUtil.isBlank(rootDept.getDeptName())) { + updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + } + updateParam.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + updateParam.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + updateParam.setDeptCategory(DeptCategory.GROUP.getCode()); + updateParam.setParentId(BladeConstant.TOP_PARENT_ID); + updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + this.updateById(updateParam); + rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setDeptCategory(DeptCategory.GROUP.getCode()); + rootDept.setParentId(BladeConstant.TOP_PARENT_ID); + rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}", + rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory()); + } + } + this.ensureExternalOrgTopDept(); + return rootDept; + } + + /** + * 确保存在独立的「外部组织」顶级(与 id=135 集团并存,仅保留一个同名顶级) + */ + private Dept ensureExternalOrgTopDept() { + List externalTops = this.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptName, OAConvertConstant.EXTERNAL_ORG_NAME) + .and(wrapper -> wrapper + .isNull(Dept::getParentId) + .or() + .eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID)) + .ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .orderByAsc(Dept::getId)); + if (CollectionUtil.isNotEmpty(externalTops)) { + Dept keep = externalTops.get(0); + if (externalTops.size() > 1) { + List duplicateIds = externalTops.stream() + .skip(1) + .map(Dept::getId) + .filter(Objects::nonNull) + .toList(); + if (CollectionUtil.isNotEmpty(duplicateIds)) { + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, keep.getId()) + .in(Dept::getParentId, duplicateIds)); + this.removeByIds(duplicateIds); + log.info("已合并重复的外部组织顶级,保留 id={},删除{}", keep.getId(), duplicateIds); + } + } + return keep; + } + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + Dept externalDept = new Dept(); + externalDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); + externalDept.setTenantId(tenantId); + externalDept.setParentId(BladeConstant.TOP_PARENT_ID); + externalDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + externalDept.setDeptName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setFullName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setShortName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setDeptCode(null); + externalDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + externalDept.setDeptCategory(DeptCategory.GROUP.getCode()); + externalDept.setSort(0); + externalDept.setStatus(DataStatusEnum.ENABLE.getCode()); + externalDept.setIsDeleted(BladeConstant.DB_NOT_DELETED); + externalDept.setIsOa(0); + externalDept.setIsPlatformCompany(0); + externalDept.setSyncTime(new Date()); + this.save(externalDept); + log.info("已创建外部组织顶级 id={}", externalDept.getId()); + return externalDept; + } + + /** + * 清理历史伪顶级(OACROOT);同名「桂物物流集团」仅清理非 id=135 的顶级 + */ + private void removeLegacyOaSyncRoots() { + List legacyRoots = this.list(Wrappers.lambdaQuery() + .ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .and(wrapper -> wrapper + .eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE) + .or(nameWrapper -> nameWrapper + .eq(Dept::getDeptName, OAConvertConstant.LEGACY_ROOT_DEPT_NAME) + .eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE) + .and(parentWrapper -> parentWrapper + .isNull(Dept::getParentId) + .or() + .eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID))))); + if (CollectionUtil.isEmpty(legacyRoots)) { + return; + } + Set legacyIds = legacyRoots.stream().map(Dept::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .in(Dept::getParentId, legacyIds)); + this.removeByIds(legacyIds); + log.info("已清理历史伪顶级组织{}条:{}", legacyIds.size(), legacyIds); + } + + /** + * 按编码前缀对齐组织类型:OAC→公司、OAD→部门(覆盖历史 1/2 映射) + */ + private void migrateLegacyOaDeptCategory() { + this.update(Wrappers.lambdaUpdate() + .set(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.DEPARTMENT_OA_PREFIX)); + this.update(Wrappers.lambdaUpdate() + .set(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.COMPANY_OA_PREFIX) + .ne(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)); } @Override @@ -434,10 +612,14 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (dept.getIsPlatformCompany() == null) { dept.setIsPlatformCompany(0); } + // 排序未填时默认 1 + if (dept.getSort() == null) { + dept.setSort(1); + } if (Func.isEmpty(dept.getTenantId())) { throw new ServiceException("租户ID不能为空"); } - validateDeptCategory(dept, parent); + validateDeptCategory(dept); validateDeptCode(dept, parent); return saveOrUpdate(dept); } @@ -619,27 +801,8 @@ public class DeptServiceImpl extends ServiceImpl implements ID return "Basic " + value; } - private void validateDeptCategory(Dept dept, Dept parent) { - // 顶级组织不要求选择上级组织 - if (parent == null) { - if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) { - throw new ServiceException("请选择承运商"); - } - return; - } - List allowedCategories; - if (BladeConstant.TOP_PARENT_ID.equals(parent.getParentId())) { - allowedCategories = "外部组织".equals(parent.getDeptName()) ? List.of(6) : List.of(1); - } else if (Integer.valueOf(1).equals(parent.getDeptCategory())) { - allowedCategories = List.of(2, 3); - } else if (List.of(2, 3).contains(parent.getDeptCategory())) { - allowedCategories = List.of(4, 5, 6); - } else { - throw new ServiceException("上级组织层级不支持新增下级组织"); - } - if (!allowedCategories.contains(dept.getDeptCategory())) { - throw new ServiceException("组织类型不符合上级组织层级规则"); - } + private void validateDeptCategory(Dept dept) { + // 组织树层级仅由 parentId / ancestors 决定,组织类型不再做上下级约束 if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) { throw new ServiceException("请选择承运商"); } @@ -665,9 +828,16 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (StringUtil.isBlank(parent.getDeptCode())) { throw new ServiceException("请选择已配置组织编码的上级组织"); } - String pattern = Pattern.quote(parent.getDeptCode()) + "-\\d+"; - if (!deptCode.matches(pattern)) { - throw new ServiceException("组织编码格式应为:上级编码-分段数字"); + // 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字 + String expectedParentCode = parent.getDeptCode(); + if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + expectedParentCode = OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE; + } else if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory()) + && StringUtil.isNotBlank(dept.getBelongCompanyCode())) { + expectedParentCode = dept.getBelongCompanyCode(); + } + if (!org.springblade.system.util.DeptCodeUtil.matchesChildCode(expectedParentCode, deptCode)) { + throw new ServiceException("组织编码格式应为:" + expectedParentCode + "三位数字(不足补零,中间不加-)"); } this.ensureDeptCodeUnique(dept, deptCode); dept.setDeptCode(deptCode); @@ -739,8 +909,8 @@ public class DeptServiceImpl extends ServiceImpl implements ID log.info("没有记录OA主键的组织,跳过层级重建"); return; } - Map companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode()); - Map departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode()); + Map companyByOaId = this.indexOaCompanies(oaDeptList); + Map departmentByOaId = this.indexOaDepartments(oaDeptList); Map desiredParentIdMap = new LinkedHashMap<>(); Map desiredParentCodeMap = new HashMap<>(); for (Dept dept : oaDeptList) { @@ -793,21 +963,20 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (CollectionUtil.isEmpty(oaDeptList)) { return 0; } - Map companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode()); - Map departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode()); + Map companyByOaId = this.indexOaCompanies(oaDeptList); + Map departmentByOaId = this.indexOaDepartments(oaDeptList); Set deleteIds = new HashSet<>(); for (Dept dept : oaDeptList) { String parentOaId = dept.getOaSupSubComId(); - // 上级为空或为 0:视为本地锚点/根级,不作为孤儿删除 + // 上级为空或为 0:OA 顶级公司挂本地锚点,不作为孤儿 if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) { continue; } - boolean parentExists; - if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { - parentExists = companyByOaId.containsKey(parentOaId); - } else { - parentExists = departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId); - } + boolean isCompany = this.isOaCompany(dept); + boolean parentExists = isCompany + ? companyByOaId.containsKey(parentOaId) + : departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId); + // 全量数据整理后仍找不到上级:公司、部门一律删除,不改挂保留 if (!parentExists) { deleteIds.add(dept.getId()); } @@ -828,7 +997,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) { continue; } - Dept parent = DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory()) + Dept parent = this.isOaCompany(dept) ? companyByOaId.get(parentOaId) : Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId)); if (parent != null && deleteIds.contains(parent.getId())) { @@ -857,6 +1026,72 @@ public class DeptServiceImpl extends ServiceImpl implements ID return deleteIds.size(); } + /** + * 索引 OA 公司:同时兼容类型迁移前后的公司记录 + */ + private Map indexOaCompanies(List oaDeptList) { + Map oaIdMap = new HashMap<>(); + for (Dept dept : oaDeptList) { + if (!this.isOaCompany(dept) || StringUtil.isBlank(dept.getOaId())) { + continue; + } + Dept previous = oaIdMap.put(dept.getOaId(), dept); + if (previous != null) { + log.warn("OA公司主键重复,层级重建使用后一条 oaId={} id={} previousId={}", + dept.getOaId(), dept.getId(), previous.getId()); + } + } + return oaIdMap; + } + + private boolean isOaCompany(Dept dept) { + if (dept == null) { + return false; + } + if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + return true; + } + if (org.springblade.system.util.DeptCodeUtil.isCompanyCode(dept.getDeptCode())) { + return true; + } + return StringUtil.isNotBlank(dept.getDeptCode()) + && dept.getDeptCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX) + && !OAConvertConstant.LEGACY_ROOT_DEPT_CODE.equals(dept.getDeptCode()); + } + + private boolean isOaDepartment(Dept dept) { + if (dept == null) { + return false; + } + if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) { + return true; + } + if (StringUtil.isNotBlank(dept.getBelongCompanyCode()) + && org.springblade.system.util.DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode())) { + return true; + } + return StringUtil.isNotBlank(dept.getDeptCode()) + && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX); + } + + /** + * 索引 OA 部门:兼容类型迁移前后 + */ + private Map indexOaDepartments(List oaDeptList) { + Map oaIdMap = new HashMap<>(); + for (Dept dept : oaDeptList) { + if (!this.isOaDepartment(dept) || StringUtil.isBlank(dept.getOaId())) { + continue; + } + Dept previous = oaIdMap.put(dept.getOaId(), dept); + if (previous != null) { + log.warn("OA部门主键重复,层级重建使用后一条 oaId={} id={} previousId={}", + dept.getOaId(), dept.getId(), previous.getId()); + } + } + return oaIdMap; + } + /** * 按机构类型建立 OA 主键索引 */ @@ -881,19 +1116,22 @@ public class DeptServiceImpl extends ServiceImpl implements ID private OaParentLink resolveOaParentLink(Dept dept, Map companyByOaId, Map departmentByOaId) { String parentOaId = dept.getOaSupSubComId(); boolean rootParent = StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId); - if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + if (this.isOaCompany(dept)) { if (rootParent) { - return new OaParentLink(OAConvertConstant.ROOT_PARENT_ID, OAConstant.ROOT_COMPANY_ID); + // OA 顶级公司统一挂到本地顶级锚点下 + return new OaParentLink(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); } Dept parent = companyByOaId.get(parentOaId); if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) { + // 全量整理后仍无上级:跳过重建,交由孤儿清理直接删除 log.warn("未找到上级公司,跳过层级重建 oaId={} oaSupSubComId={} deptId={}", dept.getOaId(), parentOaId, dept.getId()); return null; } return new OaParentLink(parent.getId(), this.resolveParentCode(parent, OAConvertConstant.COMPANY_OA_PREFIX, parentOaId)); } - if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) { + if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory()) + && !(StringUtil.isNotBlank(dept.getDeptCode()) && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX))) { return null; } if (rootParent) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java index df18f4f4..4c43c474 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java @@ -50,7 +50,7 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl DIMENSIONS = Set.of("重量", "体积", "数量"); + private static final Set DIMENSIONS = Set.of("重量", "体积", "数量", "整车", "里程", "吨·公里"); @Override public IPage selectMeasurementUnitPage(IPage page, diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java new file mode 100644 index 00000000..93f744ba --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -0,0 +1,380 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.service.impl; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.common.utils.PasswordRuleUtil; +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.support.Kv; +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.system.cache.MpUserCache; +import org.springblade.system.mapper.MpUserMapper; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.service.IMpUserService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 小程序用户 服务实现类 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class MpUserServiceImpl extends BaseServiceImpl implements IMpUserService { + + @Override + public MpUser getByPhone(String tenantId, String phone) { + if (Func.isBlank(tenantId) || Func.isBlank(phone)) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getPhone, phone) + .orderByDesc(MpUser::getCreateTime) + .last("LIMIT 1")); + } + + @Override + public MpUser getByAccount(String tenantId, String account) { + if (Func.isBlank(tenantId) || Func.isBlank(account)) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getAccount, account) + .orderByDesc(MpUser::getCreateTime) + .last("LIMIT 1")); + } + + @Override + public MpUserInfo userInfo(Long userId) { + if (Func.isEmpty(userId)) { + return null; + } + return buildUserInfo(getById(userId)); + } + + @Override + public MpUserInfo userInfoByPhone(String tenantId, String phone) { + return buildUserInfo(getByPhone(tenantId, phone)); + } + + @Override + public MpUserInfo userInfoByAccount(String tenantId, String account) { + return buildUserInfo(getByAccount(tenantId, account)); + } + + @Override + public IPage selectMpUserPage(IPage page, MpUser query) { + MpUser condition = query == null ? new MpUser() : query; + return page(page, Wrappers.lambdaQuery() + .like(Func.isNotBlank(condition.getPhone()), MpUser::getPhone, condition.getPhone()) + .like(Func.isNotBlank(condition.getAccount()), MpUser::getAccount, condition.getAccount()) + .like(Func.isNotBlank(condition.getRealName()), MpUser::getRealName, condition.getRealName()) + .like(Func.isNotBlank(condition.getName()), MpUser::getName, condition.getName()) + .like(Func.isNotBlank(condition.getIdentities()), MpUser::getIdentities, condition.getIdentities()) + .eq(condition.getStatus() != null, MpUser::getStatus, condition.getStatus()) + .orderByDesc(MpUser::getCreateTime)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean submit(MpUser user) { + prepare(user); + validate(user); + MpUser existed = getByPhone(user.getTenantId(), user.getPhone()); + if (existed != null && Func.isNotEmpty(existed.getId())) { + throw new ServiceException("该手机号已存在小程序用户"); + } + encryptPasswordIfPresent(user); + boolean saved = save(user); + if (saved) { + MpUserCache.clear(); + } + return saved; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateUser(MpUser user) { + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户主键不能为空"); + } + prepare(user); + validate(user); + MpUser existed = getByPhone(user.getTenantId(), user.getPhone()); + if (existed != null && Func.isNotEmpty(existed.getId()) && !existed.getId().equals(user.getId())) { + throw new ServiceException("该手机号已存在小程序用户"); + } + // 后台编辑不改密码,避免空串覆盖 + user.setPassword(null); + boolean updated = updateById(user); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean submitOrUpdate(MpUser user) { + if (user != null && Func.isNotEmpty(user.getId())) { + return updateUser(user); + } + return submit(user); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeMpUser(List ids) { + if (Func.isEmpty(ids)) { + throw new ServiceException("请选择要删除的小程序用户"); + } + boolean removed = deleteLogic(ids); + if (removed) { + MpUserCache.clear(); + } + return removed; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean changeStatus(Long id, Integer status) { + if (Func.isEmpty(id)) { + throw new ServiceException("小程序用户主键不能为空"); + } + if (status == null || (status != 0 && status != 1)) { + throw new ServiceException("状态仅支持启用(1)或停用(0)"); + } + MpUser user = getById(id); + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户不存在"); + } + MpUser patch = new MpUser(); + patch.setId(id); + patch.setStatus(status); + boolean updated = updateById(patch); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map resetPassword(List userIds) { + if (Func.isEmpty(userIds)) { + throw new ServiceException("请选择要重置密码的小程序用户"); + } + Map passwordMap = new LinkedHashMap<>(); + for (Long userId : userIds) { + MpUser user = getById(userId); + if (user == null || Func.isEmpty(user.getId())) { + continue; + } + String password = PasswordRuleUtil.generate(); + MpUser patch = new MpUser(); + patch.setId(userId); + patch.setPassword(DigestUtil.encrypt(password)); + patch.setUpdateTime(DateUtil.now()); + updateById(patch); + String account = Func.toStr(user.getAccount(), user.getPhone()); + passwordMap.put(account, password); + } + if (passwordMap.isEmpty()) { + throw new ServiceException("未找到可重置密码的小程序用户"); + } + MpUserCache.clear(); + return passwordMap; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean bindOpenId(String tenantId, Long userId, String openid) { + if (Func.isBlank(tenantId) || Func.isEmpty(userId) || Func.isBlank(openid)) { + throw new ServiceException("绑定 openid 参数不完整"); + } + MpUser user = getById(userId); + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户不存在"); + } + if (!tenantId.equals(user.getTenantId())) { + throw new ServiceException("租户不匹配,无法绑定 openid"); + } + MpUser byOpenId = getOne(Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getOpenid, openid) + .last("LIMIT 1")); + if (byOpenId != null && !byOpenId.getId().equals(userId)) { + throw new ServiceException("该微信已绑定其他小程序用户"); + } + MpUser patch = new MpUser(); + patch.setId(userId); + patch.setOpenid(openid); + boolean updated = updateById(patch); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean setPassword(Long userId, String password, String password2) { + if (!Objects.equals(password, password2)) { + throw new ServiceException("两次输入密码不一致!"); + } + if (!PasswordRuleUtil.isValid(password)) { + throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE); + } + MpUser user = getById(userId); + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户不存在"); + } + MpUser patch = new MpUser(); + patch.setId(userId); + patch.setPassword(DigestUtil.encrypt(password)); + patch.setUpdateTime(DateUtil.now()); + boolean updated = updateById(patch); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + private MpUserInfo buildUserInfo(MpUser user) { + if (user == null || Func.isEmpty(user.getId())) { + return null; + } + List identities = MpUserIdentity.split(user.getIdentities()); + if (identities.isEmpty()) { + throw new ServiceException("小程序用户未配置身份,无法登录"); + } + MpUserInfo userInfo = new MpUserInfo(); + userInfo.setUser(user); + userInfo.setIdentities(identities); + userInfo.setPermissions(MpUserIdentity.toPermissions(user.getIdentities())); + userInfo.setDetail(Kv.create() + .set("type", MpUserIdentity.DETAIL_TYPE) + .set("identities", user.getIdentities()) + .set("openid", Func.toStr(user.getOpenid(), ""))); + return userInfo; + } + + private void prepare(MpUser user) { + if (user == null) { + return; + } + if (Func.isBlank(user.getTenantId())) { + user.setTenantId(AuthUtil.getTenantId()); + } + user.setAccount(trimToNull(user.getAccount())); + user.setPhone(trimToNull(user.getPhone())); + user.setName(trimToNull(user.getName())); + user.setRealName(trimToNull(user.getRealName())); + user.setAvatar(trimToNull(user.getAvatar())); + user.setEmail(trimToNull(user.getEmail())); + user.setOpenid(trimToNull(user.getOpenid())); + user.setDeptId(trimToNull(user.getDeptId())); + user.setPostId(trimToNull(user.getPostId())); + user.setRemark(trimToNull(user.getRemark())); + user.setIdentities(normalizeIdentities(user.getIdentities())); + if (Func.isBlank(user.getAccount()) && Func.isNotBlank(user.getPhone())) { + user.setAccount(user.getPhone()); + } + if (Func.isBlank(user.getName()) && Func.isNotBlank(user.getRealName())) { + user.setName(user.getRealName()); + } + if (user.getStatus() == null) { + user.setStatus(1); + } + } + + private void encryptPasswordIfPresent(MpUser user) { + if (user == null || Func.isBlank(user.getPassword())) { + user.setPassword(null); + return; + } + String password = user.getPassword().trim(); + if (!PasswordRuleUtil.isValid(password)) { + throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE); + } + user.setPassword(DigestUtil.encrypt(password)); + } + + private void validate(MpUser user) { + if (user == null) { + throw new ServiceException("小程序用户不能为空"); + } + if (Func.isBlank(user.getTenantId())) { + throw new ServiceException("租户信息不能为空"); + } + if (Func.isBlank(user.getPhone())) { + throw new ServiceException("手机号不能为空"); + } + if (Func.isBlank(user.getIdentities())) { + throw new ServiceException("小程序身份不能为空"); + } + List identities = MpUserIdentity.split(user.getIdentities()); + if (identities.isEmpty()) { + throw new ServiceException("小程序身份不能为空"); + } + for (String code : identities) { + if (MpUserIdentity.of(code) == null) { + throw new ServiceException("不支持的小程序身份:" + code); + } + } + } + + private String normalizeIdentities(String identities) { + List identityList = MpUserIdentity.split(identities); + if (identityList.isEmpty()) { + return null; + } + return StringUtil.join(identityList); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimValue = value.trim(); + return trimValue.isEmpty() ? null : trimValue; + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index 7bd93f98..2156b11f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -22,6 +22,7 @@ import org.springblade.system.pojo.vo.OaOrgSyncPageVO; import org.springblade.system.pojo.vo.OaPersonSyncPageVO; import org.springblade.system.service.*; import org.springblade.system.util.DataSyncRecordUtils; +import org.springblade.system.util.DeptCodeUtil; import org.springblade.thirdparty.oa.constant.OAConstant; import org.springblade.thirdparty.oa.constant.OAConvertConstant; import org.springblade.thirdparty.oa.feign.IOAClient; @@ -60,6 +61,7 @@ public class OASyncServiceImpl implements IOASyncService { private final IOAClient oaClient; private final DeptConvert deptConvert; private final IDeptService deptService; + private final IUserService userService; private final IRoleService roleService; private final IMKPushService mkPushService; private final IDataSyncRecordService dataSyncRecordService; @@ -72,6 +74,8 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.addLog(log); // 1. 同步公司和部门并记录 Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> { + // 0. 确保双顶级:id=135 集团(GWWL) + 独立「外部组织」 + deptService.ensureOaSyncRootDept(); // 1. 同步公司 this.syncCompany(queryStartTime); // 公司分批入库后,重建层级并清理无法挂载的公司 @@ -124,10 +128,10 @@ public class OASyncServiceImpl implements IOASyncService { @Transactional(rollbackFor = Exception.class) @Override - public OaPersonSyncPageVO syncPersonFromUserList(int current, int size) { + public OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince) { try { ComposeLogUtil.addLog(log); - return this.syncPersonFromOaPage(current, size); + return this.syncPersonFromOaPage(current, size, incremental, modifiedSince); } finally { ComposeLogUtil.removeLastLog(); } @@ -135,10 +139,10 @@ public class OASyncServiceImpl implements IOASyncService { @Transactional(rollbackFor = Exception.class) @Override - public OaOrgSyncPageVO syncCompanyPage(int current, int size) { + public OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince) { try { ComposeLogUtil.addLog(log); - return this.syncCompanyFromOaPage(current, size); + return this.syncCompanyFromOaPage(current, size, incremental, modifiedSince); } finally { ComposeLogUtil.removeLastLog(); } @@ -146,10 +150,10 @@ public class OASyncServiceImpl implements IOASyncService { @Transactional(rollbackFor = Exception.class) @Override - public OaOrgSyncPageVO syncDepartmentPage(int current, int size) { + public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince) { try { ComposeLogUtil.addLog(log); - return this.syncDepartmentFromOaPage(current, size); + return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince); } finally { ComposeLogUtil.removeLastLog(); } @@ -280,14 +284,17 @@ public class OASyncServiceImpl implements IOASyncService { /** * 按页从 OA 人员列表同步组织与人员 * - * @param current 当前页 - * @param size 每页条数 + * @param current 当前页 + * @param size 每页条数 + * @param incremental 是否增量同步 + * @param modifiedSince 增量起始时间(后续分页回传) * @return 本页同步结果 */ - private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size) { + 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); - OAPersonSearch personSearch = buildPersonSearch(null); + IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince); + OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime()); personSearch.setCurPage(pageNo); personSearch.setPageSize(pageSize); OAResponse oaResponse = oaClient.queryPersonPage(new OASearch<>(personSearch)); @@ -311,26 +318,35 @@ public class OASyncServiceImpl implements IOASyncService { pageVO.setFetchedCount(oaPersons.size()); pageVO.setSyncedCount(personSyncCount.getSyncedCount()); pageVO.setSkippedCount(personSyncCount.getSkippedCount()); + pageVO.setModifiedSince(incrementalStart.getModifiedSince()); boolean finished = oaPersons.isEmpty() || oaPersons.size() < pageSize || (long) pageNo * pageSize >= totalSize; pageVO.setFinished(finished); - ComposeLogUtil.getLastLog().info("OA人员分页同步完成 {}/{},成功{},跳过{}", - pageNo, totalSize, personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); + ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}", + incremental ? "incremental" : "full", pageNo, totalSize, + personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); return pageVO; } /** * 按页从 OA 公司接口同步公司 * - * @param current 当前页 - * @param size 每页条数 + * @param current 当前页 + * @param size 每页条数 + * @param incremental 是否增量同步 + * @param modifiedSince 增量起始时间(后续分页回传) * @return 本页同步结果 */ - private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size) { + private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); - OACompanySearch companySearch = buildCompanySearch(null); + // 首屏确保双顶级:id=135 集团(GWWL) + 独立「外部组织」 + if (pageNo == 1) { + deptService.ensureOaSyncRootDept(); + } + IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY); + OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime()); companySearch.setCurPage(pageNo); companySearch.setPageSize(pageSize); OAResponse oaResponse = oaClient.queryCompanyPage(new OASearch<>(companySearch)); @@ -347,6 +363,7 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); } OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount); + pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { // 公司全部分批入库后:重建层级,并删除上级不存在的数据 deptService.rebuildOaHierarchy(); @@ -354,19 +371,22 @@ public class OASyncServiceImpl implements IOASyncService { } CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); - ComposeLogUtil.getLastLog().info("OA公司分页同步完成 {}/{},成功{},跳过{}", - pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount()); + ComposeLogUtil.getLastLog().info("OA公司分页同步完成 mode={} {}/{},成功{},跳过{}", + incremental ? "incremental" : "full", pageNo, totalSize, + syncCount.getSyncedCount(), syncCount.getSkippedCount()); return pageVO; } /** * 按页从 OA 部门接口同步部门 * - * @param current 当前页 - * @param size 每页条数 + * @param current 当前页 + * @param size 每页条数 + * @param incremental 是否增量同步 + * @param modifiedSince 增量起始时间(后续分页回传) * @return 本页同步结果 */ - private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size) { + private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); String subCompanyIds = getOaSubCompanyIds(true); @@ -376,7 +396,8 @@ public class OASyncServiceImpl implements IOASyncService { emptyPageVO.setFinished(true); return emptyPageVO; } - OADepartmentSearch departmentSearch = buildDepartmentSearch(null, subCompanyIds); + IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT); + OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), subCompanyIds); departmentSearch.setCurPage(pageNo); departmentSearch.setPageSize(pageSize); OAResponse oaResponse = oaClient.queryDepartmentPage(new OASearch<>(departmentSearch)); @@ -393,6 +414,7 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); } OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount); + pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { // 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表 deptService.rebuildOaHierarchy(); @@ -401,11 +423,118 @@ public class OASyncServiceImpl implements IOASyncService { } CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); - ComposeLogUtil.getLastLog().info("OA部门分页同步完成 {}/{},成功{},跳过{}", - pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount()); + ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} {}/{},成功{},跳过{}", + incremental ? "incremental" : "full", pageNo, totalSize, + syncCount.getSyncedCount(), syncCount.getSkippedCount()); return pageVO; } + /** + * 解析增量同步起始时间:优先使用前端回传值,保证同阶段分页起点不变 + */ + private IncrementalStart resolveIncrementalStart(boolean incremental, String modifiedSince, DeptCategory deptCategory) { + if (!incremental) { + return IncrementalStart.empty(); + } + if (StringUtils.isNotBlank(modifiedSince)) { + try { + Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME); + return new IncrementalStart(startTime, modifiedSince.trim()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析回传 modifiedSince 失败:{},重新计算", modifiedSince); + } + } + String codePrefix = DeptCategory.COMPANY.equals(deptCategory) + ? OAConvertConstant.COMPANY_OA_PREFIX + : OAConvertConstant.DEPARTMENT_OA_PREFIX; + Dept latest = deptService.getOne(Wrappers.lambdaQuery() + .select(Dept::getOaModified) + .eq(Dept::getIsOa, YES) + .and(wrapper -> wrapper + .eq(Dept::getDeptCategory, deptCategory.getCode()) + .or() + .likeRight(Dept::getDeptCode, codePrefix)) + .isNotNull(Dept::getOaModified) + .ne(Dept::getOaModified, "") + .orderByDesc(Dept::getOaModified) + .last("limit 1"), false); + if (latest == null || StringUtils.isBlank(latest.getOaModified())) { + ComposeLogUtil.getLastLog().info("本地无 {} oaModified 记录,增量同步按全量拉取后按字段比对跳过", + deptCategory.getName()); + return IncrementalStart.empty(); + } + try { + Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME); + ComposeLogUtil.getLastLog().info("增量同步{} OA modified 起始时间:{}", + deptCategory.getName(), latest.getOaModified()); + return new IncrementalStart(startTime, latest.getOaModified()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析本地最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified()); + return IncrementalStart.empty(); + } + } + + /** + * 人员增量同步起始时间:优先回传值,否则取本地 OA 用户最大 oaModified + */ + private IncrementalStart resolvePersonIncrementalStart(boolean incremental, String modifiedSince) { + if (!incremental) { + return IncrementalStart.empty(); + } + if (StringUtils.isNotBlank(modifiedSince)) { + try { + Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME); + return new IncrementalStart(startTime, modifiedSince.trim()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析人员回传 modifiedSince 失败:{},重新计算", modifiedSince); + } + } + User latest = userService.getOne(Wrappers.lambdaQuery() + .select(User::getOaModified) + .eq(User::getIsOa, YES) + .isNotNull(User::getOaModified) + .ne(User::getOaModified, "") + .orderByDesc(User::getOaModified) + .last("limit 1"), false); + if (latest == null || StringUtils.isBlank(latest.getOaModified())) { + ComposeLogUtil.getLastLog().info("本地无人员 oaModified 记录,增量同步按全量拉取后按字段比对跳过"); + return IncrementalStart.empty(); + } + try { + Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME); + ComposeLogUtil.getLastLog().info("增量同步人员 OA modified 起始时间:{}", latest.getOaModified()); + return new IncrementalStart(startTime, latest.getOaModified()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析本地人员最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified()); + return IncrementalStart.empty(); + } + } + + /** + * 增量同步起点 + */ + private static class IncrementalStart { + private final Date startTime; + private final String modifiedSince; + + private IncrementalStart(Date startTime, String modifiedSince) { + this.startTime = startTime; + this.modifiedSince = modifiedSince; + } + + private static IncrementalStart empty() { + return new IncrementalStart(null, null); + } + + private Date getStartTime() { + return startTime; + } + + private String getModifiedSince() { + return modifiedSince; + } + } + /** * 组装组织分页同步结果 */ @@ -492,13 +621,8 @@ public class OASyncServiceImpl implements IOASyncService { if (CollectionUtil.isEmpty(oaCompanies)) { return OrgSyncCount.empty(); } - // 获取需要的公司名称 Set companyNames = syncAll ? Collections.emptySet() : getCompanyNames(); - // 转换数据 List allParam = oaCompanies.stream() - // 强制跳过上级公司为 0 的数据 - .filter(company -> !OAConstant.ROOT_COMPANY_ID.equals(company.getSupsubcomid())) - // 非全量同步时,筛选只要配置的公司 .filter(company -> syncAll || companyNames.contains(company.getSubcompanyname())) .map(deptConvert::company2dept) .toList(); @@ -507,14 +631,41 @@ public class OASyncServiceImpl implements IOASyncService { return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); } - // 查询数据库的部门,转换成map - Map deptMap = getAllCompanyDeptMap(); - - // 处理部门 - List notHandledList = handleDept(allParam, deptMap, DeptCategory.COMPANY); - int syncedCount = allParam.size() - notHandledList.size(); - int skippedCount = filteredSkipCount + notHandledList.size(); - return new OrgSyncCount(syncedCount, skippedCount, notHandledList); + Map existingByOaId = loadExistingByOaId(DeptCategory.COMPANY); + Map codeOwnerMap = loadCompanyCodeOwnerMap(); + Set usedCodes = new HashSet<>(codeOwnerMap.keySet()); + Map batchByOaId = new LinkedHashMap<>(); + for (Dept dept : allParam) { + this.assignCompanyCode(dept, existingByOaId, usedCodes); + batchByOaId.put(dept.getOaId(), dept); + } + // 上级公司编码:OA 上级为 0 → GWWL;否则取上级公司已分配编码 + for (Dept dept : allParam) { + if (OAConstant.ROOT_COMPANY_ID.equals(dept.getOaSupSubComId()) || StringUtils.isBlank(dept.getOaSupSubComId())) { + dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + continue; + } + Dept parent = batchByOaId.get(dept.getOaSupSubComId()); + if (parent == null) { + parent = existingByOaId.get(dept.getOaSupSubComId()); + } + if (parent != null && StringUtils.isNotBlank(parent.getDeptCode())) { + dept.setParentCode(parent.getDeptCode()); + dept.setParentId(parent.getId()); + } else { + dept.setParentCode(null); + dept.setParentId(null); + } + } + Map deptIdByCode = new HashMap<>(); + existingByOaId.values().forEach(dept -> { + if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) { + deptIdByCode.put(dept.getDeptCode(), dept.getId()); + } + }); + deptIdByCode.put(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount); } /** @@ -527,80 +678,239 @@ public class OASyncServiceImpl implements IOASyncService { if (CollectionUtil.isEmpty(oaDepts)) { return OrgSyncCount.empty(); } - // 查询所有公司的编码和id的map - Map companyDeptMap = getAllCompanyDeptMap(); - // 根公司id + Map companyByOaId = loadCompanyByOaId(); String rootCompanyId = getRootCompanyId(); if (!syncAll && rootCompanyId == null) { return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList()); } - // 根公司下要同步的部门名称 Set rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames(); - // 转换数据 List allParam = oaDepts.stream() - // 全量同步时不过滤;否则:部门的公司不是根公司,或者部门名称在根公司下需要同步的部门列表中,且是根部门 .filter(oaDept -> syncAll || rootCompanyId == null || !rootCompanyId.equals(oaDept.getSubcompanyid1()) || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname()))) - .map(dept -> deptConvert.dept2dept(dept, companyDeptMap)) + .map(dept -> deptConvert.dept2dept(dept, companyByOaId)) .toList(); int filteredSkipCount = oaDepts.size() - allParam.size(); if (CollectionUtil.isEmpty(allParam)) { return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); } - Set deptCodes = new HashSet<>(); - allParam.forEach(dept -> { - if (StringUtils.isNotEmpty(dept.getDeptCode())) { - deptCodes.add(dept.getDeptCode()); + + Map existingByOaId = loadExistingByOaId(DeptCategory.DEPT); + Map nextSerialByCompany = new HashMap<>(); + Map> usedCodesByCompany = new HashMap<>(); + // 预载各公司下已有部门编码,保证分段连续 + for (Dept existing : existingByOaId.values()) { + if (StringUtils.isBlank(existing.getBelongCompanyCode()) || StringUtils.isBlank(existing.getDeptCode())) { + continue; } - if (StringUtils.isNotEmpty(dept.getParentCode())) { - deptCodes.add(dept.getParentCode()); + usedCodesByCompany + .computeIfAbsent(existing.getBelongCompanyCode(), key -> new HashSet<>()) + .add(existing.getDeptCode()); + } + Map batchByOaId = new LinkedHashMap<>(); + for (Dept dept : allParam) { + this.assignDepartmentCode(dept, existingByOaId, companyByOaId, usedCodesByCompany, nextSerialByCompany); + batchByOaId.put(dept.getOaId(), dept); + } + // 解析上级:根部门挂公司;子部门挂上级部门(本批或已存在) + Map deptIdByCode = new HashMap<>(); + companyByOaId.values().forEach(company -> { + if (StringUtils.isNotBlank(company.getDeptCode()) && company.getId() != null) { + deptIdByCode.put(company.getDeptCode(), company.getId()); } }); - // 查询数据库的部门,转换成map。包含本页及上级编码,便于命中已入库的上级 - Map deptMap = deptCodes.isEmpty() ? new HashMap<>() : deptService.list(Wrappers.lambdaQuery() - .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) - .in(Dept::getDeptCode, deptCodes) - ).stream() - .filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode())) - .collect(Collectors.toMap(Dept::getDeptCode, Dept::getId, (a, b) -> b)); - // 公司编码也放入 map,根部门挂到公司时才能解析父级 - companyDeptMap.forEach(deptMap::putIfAbsent); - // 处理部门 - List notHandledList = this.handleDept(allParam, deptMap, DeptCategory.DEPT); - int syncedCount = allParam.size() - notHandledList.size(); - int skippedCount = filteredSkipCount + notHandledList.size(); - return new OrgSyncCount(syncedCount, skippedCount, notHandledList); + existingByOaId.values().forEach(dept -> { + if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) { + deptIdByCode.put(dept.getDeptCode(), dept.getId()); + } + }); + for (Dept dept : allParam) { + if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getParentCode()) + && DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode()) + && dept.getBelongCompanyCode().equals(dept.getParentCode())) { + continue; + } + String parentOaId = dept.getOaSupSubComId(); + Dept belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode())) + .findFirst() + .orElse(null); + if (belongCompany != null && (OAConstant.ROOT_COMPANY_ID.equals(parentOaId) + || StringUtils.equals(parentOaId, belongCompany.getOaId()))) { + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + continue; + } + Dept parentDept = batchByOaId.get(parentOaId); + if (parentDept == null) { + parentDept = existingByOaId.get(parentOaId); + } + if (parentDept != null && StringUtils.isNotBlank(parentDept.getDeptCode())) { + dept.setParentCode(parentDept.getDeptCode()); + dept.setParentId(parentDept.getId()); + } else if (belongCompany != null) { + // 上级部门尚未就绪时先挂所属公司,分批结束后按 OA 主键重建 + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + } else { + dept.setParentCode(null); + dept.setParentId(null); + } + } + return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.DEPT, filteredSkipCount); } /** - * 处理部门 - * @param allParam 待处理的部门 - * @param deptMap 数据库所有部门编码和id map - * @return 未处理的数据 + * 公司编码:GWWL+三位数字;已有合规编码则复用 */ - private List handleDept(List allParam, Map deptMap, DeptCategory deptCategory) { - // 数据库存在的所有部门id - Set existsDeptIds = new HashSet<>(deptMap.values()); + private void assignCompanyCode(Dept dept, Map existingByOaId, Set usedCodes) { + Dept existing = existingByOaId.get(dept.getOaId()); + if (existing != null && DeptCodeUtil.isCompanyCode(existing.getDeptCode())) { + dept.setDeptCode(existing.getDeptCode()); + dept.setBelongCompanyCode(existing.getDeptCode()); + usedCodes.add(existing.getDeptCode()); + return; + } + int serial = DeptCodeUtil.nextSerial(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, usedCodes); + String deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial); + while (usedCodes.contains(deptCode)) { + serial++; + deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial); + } + usedCodes.add(deptCode); + dept.setDeptCode(deptCode); + dept.setBelongCompanyCode(deptCode); + } + + /** + * 部门编码:所属公司编码+三位数字;已有合规编码则复用 + */ + private void assignDepartmentCode(Dept dept, Map existingByOaId, Map companyByOaId, + Map> usedCodesByCompany, Map nextSerialByCompany) { + Dept existing = existingByOaId.get(dept.getOaId()); + Dept belongCompany = null; + if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) { + belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode()) + || StringUtils.equals(company.getOaId(), dept.getBelongCompanyCode())) + .findFirst() + .orElse(null); + } + if (belongCompany == null && existing != null && StringUtils.isNotBlank(existing.getBelongCompanyCode())) { + String belongCode = existing.getBelongCompanyCode(); + belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), belongCode)) + .findFirst() + .orElse(null); + } + if (belongCompany == null) { + return; + } + final Dept resolvedCompany = belongCompany; + final String companyCode = resolvedCompany.getDeptCode(); + dept.setBelongCompanyCode(companyCode); + if (existing != null && DeptCodeUtil.matchesChildCode(companyCode, existing.getDeptCode())) { + dept.setDeptCode(existing.getDeptCode()); + usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()).add(existing.getDeptCode()); + return; + } + Set usedCodes = usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()); + int serial = nextSerialByCompany.computeIfAbsent(companyCode, + key -> DeptCodeUtil.nextSerial(companyCode, usedCodes)); + String deptCode = DeptCodeUtil.formatChildCode(companyCode, serial); + while (usedCodes.contains(deptCode)) { + serial++; + deptCode = DeptCodeUtil.formatChildCode(companyCode, serial); + } + nextSerialByCompany.put(companyCode, serial + 1); + usedCodes.add(deptCode); + dept.setDeptCode(deptCode); + } + + /** + * 按 oaId 加载已存在组织 + */ + private Map loadExistingByOaId(DeptCategory deptCategory) { + List deptList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, deptCategory.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") + .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId, + Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId)); + Map byOaId = new HashMap<>(); + for (Dept dept : deptList) { + if (StringUtils.isNotBlank(dept.getOaId())) { + byOaId.put(dept.getOaId(), dept); + } + } + return byOaId; + } + + /** + * 已占用的公司编码(GWWLxxx) + */ + private Map loadCompanyCodeOwnerMap() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .select(Dept::getId, Dept::getDeptCode, Dept::getOaId)); + Map codeMap = new HashMap<>(); + for (Dept dept : companyList) { + if (DeptCodeUtil.isCompanyCode(dept.getDeptCode())) { + codeMap.put(dept.getDeptCode(), dept); + } + } + return codeMap; + } + + /** + * 已同步公司:oaId → 公司 + */ + private Map loadCompanyByOaId() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "")); + Map byOaId = new HashMap<>(); + for (Dept dept : companyList) { + if (StringUtils.isNotBlank(dept.getOaId())) { + byOaId.put(dept.getOaId(), dept); + } + } + return byOaId; + } + + /** + * 持久化同步数据:按 oaId 匹配;不存在则新增;存在且 oaModified 不一致才修改 + */ + private OrgSyncCount persistSyncedDepts(List allParam, Map existingByOaId, + Map deptIdByCode, DeptCategory deptCategory, + int filteredSkipCount) { + Set existsDeptIds = existingByOaId.values().stream() + .map(Dept::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); allParam.forEach(dept -> { - if (deptMap.containsKey(dept.getDeptCode())) { - // 根据部门编码获取对应的部门id - dept.setId(deptMap.get(dept.getDeptCode())); + Dept existing = existingByOaId.get(dept.getOaId()); + if (existing != null && existing.getId() != null) { + dept.setId(existing.getId()); + } else if (StringUtils.isNotBlank(dept.getDeptCode()) && deptIdByCode.containsKey(dept.getDeptCode())) { + dept.setId(deptIdByCode.get(dept.getDeptCode())); } else { - // 没有就生成一个id dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); - deptMap.put(dept.getDeptCode(), dept.getId()); + } + if (StringUtils.isNotBlank(dept.getDeptCode())) { + deptIdByCode.put(dept.getDeptCode(), dept.getId()); } }); - // 重新把没有父id的数据设置一下父id;上级尚未入库时,新增记录先挂到根节点,全部分批完成后重建层级并清理无法挂载的数据 int unresolvedParentCount = 0; for (Dept dept : allParam) { if (dept.getParentId() != null) { continue; } - Long parentId = deptMap.get(dept.getParentCode()); + Long parentId = StringUtils.isNotBlank(dept.getParentCode()) ? deptIdByCode.get(dept.getParentCode()) : null; if (parentId != null) { dept.setParentId(parentId); continue; @@ -615,11 +925,10 @@ public class OASyncServiceImpl implements IOASyncService { deptCategory.getName(), unresolvedParentCount); } - // 父id不为空才新增;已存在的记录即使父id未解析也更新(空父id不会覆盖原值) List list = allParam.stream() + .filter(dept -> StringUtils.isNotBlank(dept.getDeptCode())) .filter(dept -> dept.getParentId() != null || existsDeptIds.contains(dept.getId())) .toList(); - // 不存在的新增 List addList = list.stream() .filter(dept -> !existsDeptIds.contains(dept.getId())) .toList(); @@ -627,18 +936,48 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size()); deptService.saveBatch(addList); } - // 存在的修改 List updateList = list.stream() .filter(dept -> existsDeptIds.contains(dept.getId())) + .filter(dept -> !shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept)) .toList(); if (CollectionUtil.isNotEmpty(updateList)) { ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size()); deptService.updateBatchById(updateList); } - // 返回仍未保存的数据 - return allParam.stream() - .filter(dept -> dept.getParentId() == null && !existsDeptIds.contains(dept.getId())) + int unchangedCount = (int) list.stream() + .filter(dept -> existsDeptIds.contains(dept.getId())) + .filter(dept -> shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept)) + .count(); + List notHandledList = allParam.stream() + .filter(dept -> StringUtils.isBlank(dept.getDeptCode()) + || (dept.getParentId() == null && !existsDeptIds.contains(dept.getId()))) .toList(); + int syncedCount = addList.size() + updateList.size(); + int skippedCount = filteredSkipCount + unchangedCount + notHandledList.size(); + if (unchangedCount > 0) { + ComposeLogUtil.getLastLog().info("跳过未变更{}:{}", deptCategory.getName(), unchangedCount); + } + return new OrgSyncCount(syncedCount, skippedCount, notHandledList); + } + + /** + * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。 + * 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。 + */ + private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) { + if (existing == null) { + return false; + } + if (incoming != null && StringUtils.isNotBlank(incoming.getDeptCode()) + && !StringUtils.equals(existing.getDeptCode(), incoming.getDeptCode())) { + return false; + } + String localModified = StringUtils.trimToEmpty(existing.getOaModified()); + String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); + if (localModified.isEmpty() || remoteModified.isEmpty()) { + return false; + } + return localModified.equals(remoteModified); } /** @@ -683,6 +1022,8 @@ public class OASyncServiceImpl implements IOASyncService { if (syncAll) { companyList = deptService.list(Wrappers.lambdaQuery() .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") ); } else { Set companyNames = this.getCompanyNames(); @@ -692,6 +1033,8 @@ public class OASyncServiceImpl implements IOASyncService { companyList = deptService.list(Wrappers.lambdaQuery() .in(Dept::getDeptName, companyNames) .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") ); } return companyList.stream() @@ -792,13 +1135,20 @@ public class OASyncServiceImpl implements IOASyncService { return Collections.emptySet(); } // 查询父部门编码是根公司的,部门类型的,根公司下部门名称的部门 + Dept rootCompany = deptService.getOne(Wrappers.lambdaQuery() + .eq(Dept::getOaId, rootCompanyId) + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .last("limit 1"), false); + if (rootCompany == null || StringUtils.isBlank(rootCompany.getDeptCode())) { + return Collections.emptySet(); + } return deptService.list(Wrappers.lambdaQuery() .in(Dept::getDeptName, rootCompanyDeptNames) .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) - .eq(Dept::getParentCode, OAConvertConstant.COMPANY_OA_PREFIX + rootCompanyId) + .eq(Dept::getParentCode, rootCompany.getDeptCode()) ).stream() - .filter(dept -> StringUtils.isNotBlank(dept.getDeptCode())) - .map(dept -> dept.getDeptCode().replace(OAConvertConstant.DEPARTMENT_OA_PREFIX, "")) + .map(Dept::getOaId) + .filter(StringUtils::isNotBlank) .collect(Collectors.toSet()); } @@ -821,11 +1171,11 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("未查询到根公司 名称:{}", companyName); return null; } - if (StringUtils.isBlank(dept.getDeptCode())) { + if (StringUtils.isBlank(dept.getOaId()) && StringUtils.isBlank(dept.getDeptCode())) { ComposeLogUtil.getLastLog().warn("根公司编码为空:{}", companyName); return null; } - // 部门编码去掉前缀,就是oa的id - return dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, ""); + return StringUtils.isNotBlank(dept.getOaId()) ? dept.getOaId() + : dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, ""); } } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index 6a8a1884..a67f4ff8 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -86,7 +86,7 @@ public class OaUserListSyncHelper { private final IRoleService roleService; /** - * 从人员数据提取二级公司、三级部门,并挂到「桂物物流集团」下 + * 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=135/GWWL) 下 * * @param oaPersons OA人员 * @return 组织索引 @@ -249,6 +249,7 @@ public class OaUserListSyncHelper { }); Map userMap = new HashMap<>(); Set existsUserIds = new HashSet<>(); + Set unchangedUserIds = new HashSet<>(); users.forEach(user -> { User existingUser = existingByAccount.get(user.getAccount()); if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) { @@ -258,6 +259,9 @@ public class OaUserListSyncHelper { user.setId(existingUser.getId()); user.setPassword(null); existsUserIds.add(existingUser.getId()); + if (shouldSkipOaUpdate(existingUser, user)) { + unchangedUserIds.add(existingUser.getId()); + } } else { user.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); user.setPostId("-1"); @@ -279,12 +283,18 @@ public class OaUserListSyncHelper { } List updateUsers = users.stream() .filter(user -> existsUserIds.contains(user.getId())) + .filter(user -> !unchangedUserIds.contains(user.getId())) .toList(); if (CollectionUtil.isNotEmpty(updateUsers)) { ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size()); userService.updateBatchById(updateUsers); } + if (!unchangedUserIds.isEmpty()) { + ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size()); + } ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount); + int syncedCount = addUsers.size() + updateUsers.size(); + skippedCount += unchangedUserIds.size(); if (userMap.isEmpty()) { return new PersonSyncCount(0, skippedCount); } @@ -295,7 +305,13 @@ public class OaUserListSyncHelper { .collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second)); Set existsUserDeptIds = new HashSet<>(userDeptMap.values()); List userDeptList = oaPersons.stream() - .filter(oaPerson -> userMap.containsKey(userConvert.resolveAccount(oaPerson))) + .filter(oaPerson -> { + String account = userConvert.resolveAccount(oaPerson); + if (!userMap.containsKey(account)) { + return false; + } + return !unchangedUserIds.contains(userMap.get(account)); + }) .map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex)) .filter(Objects::nonNull) .toList(); @@ -333,6 +349,7 @@ public class OaUserListSyncHelper { .collect(Collectors.toSet()); String defaultRoleId = getDefaultRoleId(); List updateUserParams = list.stream() + .filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId())) .filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()) && userDeptIds.getDeptIds().length() <= MAX_DEPT_ID_LENGTH) .map(userDeptIds -> { User user = new User(); @@ -347,37 +364,26 @@ public class OaUserListSyncHelper { if (CollectionUtil.isNotEmpty(updateUserParams)) { userService.updateBatchById(updateUserParams); } - return new PersonSyncCount(users.size(), skippedCount); + return new PersonSyncCount(syncedCount, skippedCount); + } + + /** + * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段 + */ + private boolean shouldSkipOaUpdate(User existing, User incoming) { + if (existing == null) { + return false; + } + String localModified = StringUtils.trimToEmpty(existing.getOaModified()); + String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); + if (localModified.isEmpty() || remoteModified.isEmpty()) { + return false; + } + return localModified.equals(remoteModified); } private Dept getOrCreateRootCompany() { - Dept rootCompany = deptService.getOne(Wrappers.lambdaQuery() - .eq(Dept::getDeptName, OAConvertConstant.ROOT_COMPANY_NAME) - .last("limit 1"), false); - if (rootCompany != null) { - return rootCompany; - } - rootCompany = new Dept(); - rootCompany.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); - rootCompany.setTenantId(resolveTenantId()); - rootCompany.setParentId(BladeConstant.TOP_PARENT_ID); - rootCompany.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); - rootCompany.setDeptName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setFullName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setShortName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setDeptCode("OACROOT"); - rootCompany.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); - rootCompany.setBelongCompanyCode("OACROOT"); - rootCompany.setDeptCategory(DeptCategory.COMPANY.getCode()); - rootCompany.setSort(0); - rootCompany.setStatus(DataStatusEnum.ENABLE.getCode()); - rootCompany.setIsDeleted(BladeConstant.DB_NOT_DELETED); - rootCompany.setIsOa(YES); - rootCompany.setIsPlatformCompany(0); - rootCompany.setSyncTime(new Date()); - deptService.save(rootCompany); - ComposeLogUtil.getLastLog().info("已创建顶级组织:{}", OAConvertConstant.ROOT_COMPANY_NAME); - return rootCompany; + return deptService.ensureOaSyncRootDept(); } private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId, diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java index 8e96be1d..a9f3fc1c 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java @@ -29,7 +29,8 @@ 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.toolkit.Wrappers; -import lombok.AllArgsConstructor; +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.tool.utils.BeanUtil; @@ -44,6 +45,7 @@ import org.springblade.system.pojo.entity.Region; import org.springblade.system.pojo.vo.PortTerminalVO; import org.springblade.system.service.IPortTerminalService; import org.springblade.system.service.IRegionService; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -66,8 +68,9 @@ import java.util.regex.Pattern; * * @author Chill */ +@Slf4j @Service -@AllArgsConstructor +@RequiredArgsConstructor public class PortTerminalServiceImpl extends BaseServiceImpl implements IPortTerminalService { private static final String CATEGORY_PORT = "港口"; @@ -76,8 +79,12 @@ public class PortTerminalServiceImpl extends BaseServiceImpl T findCause(Throwable throwable, Class type) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (type.isInstance(current)) { + return type.cast(current); + } + if (current.getCause() == current) { + break; + } + } + return null; + } + + private Throwable rootCause(Throwable throwable) { + Throwable root = throwable; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + return root; + } + + private String shortenMessage(String message) { + String value = trimToEmpty(message); + return value.length() > 200 ? value.substring(0, 200) + "..." : value; } @Override @@ -370,6 +441,26 @@ public class PortTerminalServiceImpl extends BaseServiceImpl cityList = regionService.list(Wrappers.lambdaQuery() - .eq(Region::getName, portTerminal.getCity()) - .eq(Region::getRegionLevel, 2)); - for (Region city : cityList) { - district = regionService.getOne(Wrappers.lambdaQuery() - .eq(Region::getParentCode, city.getCode()) - .eq(Region::getName, portTerminal.getDistrictName()), false); - if (Func.isNotEmpty(district)) { - break; - } - } - } - if (Func.isEmpty(district)) { - district = regionService.getOne(Wrappers.lambdaQuery() - .eq(Region::getName, portTerminal.getDistrictName()) - .eq(Region::getRegionLevel, 3), false); - } + district = findRegionByName(portTerminal.getDistrictName(), DISTRICT_REGION_LEVEL, city.getCode()); } if (Func.isEmpty(district)) { throw new ServiceException("请选择区县"); } - Region city = regionService.getById(district.getParentCode()); - if (Func.isEmpty(city)) { - throw new ServiceException("区县所属城市不存在"); - } - if (Func.isNotEmpty(portTerminal.getCity()) && !Objects.equals(portTerminal.getCity(), city.getName())) { - throw new ServiceException("区县与城市不匹配"); + if (!Objects.equals(district.getParentCode(), city.getCode())) { + throw new ServiceException("所属区县与所属城市不匹配"); } + + portTerminal.setProvinceCode(province.getCode()); + portTerminal.setProvinceName(province.getName()); portTerminal.setCity(city.getName()); portTerminal.setDistrictCode(district.getCode()); portTerminal.setDistrictName(district.getName()); portTerminal.setRegionCode(district.getCode()); } + /** + * 按名称查找区划,兼容「福建/福建省」「泉州/泉州市」等简写。 + */ + private Region findRegionByName(String name, int regionLevel, String parentCode) { + String normalized = trimToEmpty(name); + if (Func.isEmpty(normalized)) { + return null; + } + for (String candidate : regionNameCandidates(normalized)) { + LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery() + .eq(Region::getRegionLevel, regionLevel) + .eq(Region::getName, candidate); + if (Func.isNotEmpty(parentCode)) { + queryWrapper.eq(Region::getParentCode, parentCode); + } + Region region = regionService.getOne(queryWrapper, false); + if (Func.isNotEmpty(region)) { + return region; + } + } + return null; + } + + private List regionNameCandidates(String name) { + List candidates = new ArrayList<>(); + candidates.add(name); + if (name.endsWith("省") || name.endsWith("市") || name.endsWith("区") || name.endsWith("县") || name.endsWith("州")) { + String shortName = name.substring(0, name.length() - 1); + if (Func.isNotEmpty(shortName) && !candidates.contains(shortName)) { + candidates.add(shortName); + } + } else { + for (String suffix : List.of("省", "市", "区", "县", "州")) { + String fullName = name + suffix; + if (!candidates.contains(fullName)) { + candidates.add(fullName); + } + } + } + return candidates; + } + private void validate(PortTerminal portTerminal) { if (!CATEGORY_PORT.equals(portTerminal.getCategory()) && !CATEGORY_TERMINAL.equals(portTerminal.getCategory())) { throw new ServiceException("类型只能为港口或码头"); @@ -506,16 +630,17 @@ public class PortTerminalServiceImpl extends BaseServiceImpl - * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is - * not liable for any claims arising from secondary or illegal development. - *

* Author: Chill Zhuang (bladejava@qq.com) */ package org.springblade.system.service.impl; @@ -28,7 +25,6 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.redis.cache.BladeRedis; import org.springblade.core.secure.utils.AuthUtil; @@ -37,21 +33,21 @@ import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.resource.feign.ISmsClient; import org.springblade.resource.utils.SmsUtil; +import org.springblade.system.cache.MpUserCache; import org.springblade.system.pojo.dto.PhoneChangeDTO; import org.springblade.system.pojo.dto.PhoneVerifyDTO; -import org.springblade.system.pojo.entity.User; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.service.IMpUserService; import org.springblade.system.service.IUserPhoneService; -import org.springblade.system.service.IUserService; +import org.springblade.system.utils.MpUserAuth; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.util.regex.Pattern; -import static org.springblade.core.cache.constant.CacheConstant.USER_CACHE; - /** - * 用户手机号变更服务实现 + * 小程序用户手机号变更服务实现(写入 blade_mp_user) * * @author Chill */ @@ -68,24 +64,22 @@ public class UserPhoneServiceImpl implements IUserPhoneService { /** * 原手机号已校验凭证(Redis) */ - private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:user:phone:change:verified:"; + private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:mp-user:phone:change:verified:"; private static final Duration PHONE_CHANGE_VERIFIED_TTL = Duration.ofMinutes(15); private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); - private final IUserService userService; + private final IMpUserService mpUserService; private final ISmsClient smsClient; private final BladeRedis bladeRedis; @Override public R sendCode(String phone) { + assertMpLogin(); String normalizedPhone = normalizePhone(phone); Long userId = AuthUtil.getUserId(); - if (Func.isEmpty(userId)) { - throw new ServiceException("请先登录"); - } - User currentUser = requireCurrentUser(userId); + MpUser currentUser = requireCurrentUser(userId); String tenantId = Func.toStr(currentUser.getTenantId(), AuthUtil.getTenantId()); boolean isCurrentPhone = StringUtil.equals(normalizedPhone, Func.toStr(currentUser.getPhone())); if (!isCurrentPhone) { @@ -100,16 +94,14 @@ public class UserPhoneServiceImpl implements IUserPhoneService { @Override public boolean verifyOldPhone(PhoneVerifyDTO phoneVerify) { + assertMpLogin(); Long userId = AuthUtil.getUserId(); - if (Func.isEmpty(userId)) { - throw new ServiceException("请先登录"); - } String id = Func.toStr(phoneVerify.getId()).trim(); String code = Func.toStr(phoneVerify.getCode()).trim(); if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) { throw new ServiceException("请先获取并填写验证码"); } - User currentUser = requireCurrentUser(userId); + MpUser currentUser = requireCurrentUser(userId); String oldPhone = Func.toStr(currentUser.getPhone()).trim(); if (StringUtil.isBlank(oldPhone)) { throw new ServiceException("当前账号未绑定手机号"); @@ -122,10 +114,8 @@ public class UserPhoneServiceImpl implements IUserPhoneService { @Override @Transactional(rollbackFor = Exception.class) public boolean changePhone(PhoneChangeDTO phoneChange) { + assertMpLogin(); Long userId = AuthUtil.getUserId(); - if (Func.isEmpty(userId)) { - throw new ServiceException("请先登录"); - } String verified = Func.toStr(bladeRedis.get(PHONE_CHANGE_VERIFIED_KEY + userId)); if (!StringUtil.equals(verified, "1")) { throw new ServiceException("请先完成原手机号验证"); @@ -136,7 +126,7 @@ public class UserPhoneServiceImpl implements IUserPhoneService { if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) { throw new ServiceException("请先获取并填写验证码"); } - User currentUser = requireCurrentUser(userId); + MpUser currentUser = requireCurrentUser(userId); String oldPhone = Func.toStr(currentUser.getPhone()).trim(); if (StringUtil.equals(newPhone, oldPhone)) { throw new ServiceException("新手机号不可与当前手机号相同"); @@ -145,27 +135,49 @@ public class UserPhoneServiceImpl implements IUserPhoneService { assertPhoneAvailable(tenantId, newPhone, userId); validateSms(tenantId, id, code, newPhone); - User updateUser = new User(); + MpUser updateUser = new MpUser(); updateUser.setId(userId); + updateUser.setTenantId(tenantId); updateUser.setPhone(newPhone); + updateUser.setName(currentUser.getName()); + updateUser.setRealName(currentUser.getRealName()); + updateUser.setIdentities(currentUser.getIdentities()); + updateUser.setSex(currentUser.getSex()); + updateUser.setDeptId(currentUser.getDeptId()); + updateUser.setPostId(currentUser.getPostId()); + updateUser.setAvatar(currentUser.getAvatar()); + updateUser.setEmail(currentUser.getEmail()); + updateUser.setRemark(currentUser.getRemark()); + updateUser.setStatus(currentUser.getStatus()); // 账号若等于原手机号,同步更新,保证短信登录可用 if (StringUtil.isNotBlank(oldPhone) && StringUtil.equals(oldPhone, Func.toStr(currentUser.getAccount()))) { assertAccountAvailable(tenantId, newPhone, userId); updateUser.setAccount(newPhone); + } else { + updateUser.setAccount(currentUser.getAccount()); } - boolean updated = userService.updateById(updateUser); + boolean updated = mpUserService.updateUser(updateUser); if (!updated) { throw new ServiceException("手机号修改失败"); } bladeRedis.del(PHONE_CHANGE_VERIFIED_KEY + userId); - CacheUtil.clear(USER_CACHE); + MpUserCache.clear(); return true; } - private User requireCurrentUser(Long userId) { - User user = userService.getById(userId); + private void assertMpLogin() { + if (Func.isEmpty(AuthUtil.getUserId())) { + throw new ServiceException("请先登录"); + } + if (!MpUserAuth.isMpUser()) { + throw new ServiceException("仅支持小程序用户修改手机号"); + } + } + + private MpUser requireCurrentUser(Long userId) { + MpUser user = mpUserService.getById(userId); if (user == null) { - throw new ServiceException("用户不存在"); + throw new ServiceException("小程序用户不存在"); } return user; } @@ -178,11 +190,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService { } private void assertPhoneAvailable(String tenantId, String phone, Long excludeUserId) { - Long phoneCount = userService.count( - Wrappers.lambdaQuery() - .eq(User::getTenantId, tenantId) - .eq(User::getPhone, phone) - .ne(User::getId, excludeUserId) + Long phoneCount = mpUserService.count( + Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getPhone, phone) + .ne(MpUser::getId, excludeUserId) ); if (phoneCount != null && phoneCount > 0L) { throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", phone)); @@ -190,11 +202,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService { } private void assertAccountAvailable(String tenantId, String account, Long excludeUserId) { - Long accountCount = userService.count( - Wrappers.lambdaQuery() - .eq(User::getTenantId, tenantId) - .eq(User::getAccount, account) - .ne(User::getId, excludeUserId) + Long accountCount = mpUserService.count( + Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getAccount, account) + .ne(MpUser::getId, excludeUserId) ); if (accountCount != null && accountCount > 0L) { throw new ServiceException(StringUtil.format("当前用户 [{}] 已存在!", account)); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java index e356b5f8..3a463c4d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java @@ -133,8 +133,12 @@ public class UserServiceImpl extends BaseServiceImpl implement @Override public IPage selectPage(Map user, Query query) { + String account = Func.toStr(user.remove("account")).trim(); + String realName = Func.toStr(user.remove("realName")).trim(); + String phone = Func.toStr(user.remove("phone")).trim(); QueryWrapper queryWrapper = Condition.getQueryWrapper(user, User.class); applyTenantScope(queryWrapper); + applyUserFuzzyFilters(queryWrapper, account, realName, phone); return UserWrapper.build().pageVO(page(Condition.getPage(query), queryWrapper)); } @@ -335,6 +339,23 @@ public class UserServiceImpl extends BaseServiceImpl implement return submitUserInfo(user) && submitUserDept(user) && submitUserScopes(user); } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean changeUserStatus(Long userId, Integer status) { + if (Func.isEmpty(userId)) { + throw new ServiceException("用户主键不能为空"); + } + if (!Objects.equals(status, DataStatusEnum.ENABLE.getCode()) && !Objects.equals(status, 0) + && !Objects.equals(status, DataStatusEnum.DISABLE.getCode())) { + throw new ServiceException("启停状态不正确"); + } + TenantGuard.verify(this, userId, USER); + Integer targetStatus = Objects.equals(status, DataStatusEnum.ENABLE.getCode()) + ? DataStatusEnum.ENABLE.getCode() : 0; + CacheUtil.clear(USER_CACHE); + return changeStatus(Collections.singletonList(userId), targetStatus); + } + @Override public boolean updateUserInfo(User user) { // 用户修改自身信息强制指定当前请求账号的ID @@ -360,7 +381,14 @@ public class UserServiceImpl extends BaseServiceImpl implement throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone())); } user.setPassword(null); - return updateById(user); + Integer originalSex = user.getSex(); + Integer normalizedSex = normalizeUserSex(originalSex); + user.setSex(normalizedSex); + boolean updated = updateById(user); + if (user.getId() != null && originalSex != null && normalizedSex == null) { + this.update(Wrappers.lambdaUpdate().set(User::getSex, null).eq(User::getId, user.getId())); + } + return updated; } private boolean submitUserDept(User user) { @@ -378,6 +406,7 @@ public class UserServiceImpl extends BaseServiceImpl implement @Override public IPage selectUserPage(IPage page, User user, Long deptId, String tenantId) { + normalizeUserSearch(user); List deptIdList = SysCache.getDeptChildIds(deptId); return page.setRecords(baseMapper.selectUserPage(page, user, deptIdList, tenantId)); } @@ -713,8 +742,12 @@ public class UserServiceImpl extends BaseServiceImpl implement @Override public List exportUser(Map user) { + String account = Func.toStr(user.remove("account")).trim(); + String realName = Func.toStr(user.remove("realName")).trim(); + String phone = Func.toStr(user.remove("phone")).trim(); QueryWrapper queryWrapper = Condition.getQueryWrapper(user, User.class); applyTenantScope(queryWrapper); + applyUserFuzzyFilters(queryWrapper, account, realName, phone); queryWrapper.lambda().eq(User::getIsDeleted, BladeConstant.DB_NOT_DELETED); return exportUser(queryWrapper); } @@ -1077,6 +1110,7 @@ public class UserServiceImpl extends BaseServiceImpl implement throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone())); } CacheUtil.clear(USER_CACHE); + user.setSex(normalizeUserSex(user.getSex())); return save(user) && submitUserDept(user) && submitUserScopes(user); } @@ -1087,6 +1121,16 @@ public class UserServiceImpl extends BaseServiceImpl implement if (user.getIncludeNewCustomer() == null) user.setIncludeNewCustomer(0); } + /** + * 性别仅保留男 / 女 / 未知;空值或历史占位 -1 不回填,按空处理。 + */ + private Integer normalizeUserSex(Integer sex) { + if (Objects.equals(sex, 1) || Objects.equals(sex, 2) || Objects.equals(sex, 3)) { + return sex; + } + return null; + } + private boolean submitUserScopes(User user) { userDataScopeMapper.delete(Wrappers.lambdaQuery().eq(UserDataScope::getUserId, user.getId())); userCustomerScopeMapper.delete(Wrappers.lambdaQuery().eq(UserCustomerScope::getUserId, user.getId())); @@ -1127,6 +1171,35 @@ public class UserServiceImpl extends BaseServiceImpl implement } } + /** + * 归一化用户列表搜索条件,避免首尾空格导致模糊匹配失效。 + */ + private void normalizeUserSearch(User user) { + if (user == null) { + return; + } + if (user.getAccount() != null) { + user.setAccount(user.getAccount().trim()); + } + if (user.getRealName() != null) { + user.setRealName(user.getRealName().trim()); + } + if (user.getPhone() != null) { + user.setPhone(user.getPhone().trim()); + } + } + + /** + * 账号名、姓名、手机号统一按模糊匹配过滤。 + */ + private void applyUserFuzzyFilters(QueryWrapper queryWrapper, String account, String realName, String phone) { + queryWrapper.lambda().like(StringUtil.isNotBlank(account), User::getAccount, account); + if (StringUtil.isNotBlank(realName)) { + queryWrapper.lambda().and(wrapper -> wrapper.like(User::getRealName, realName).or().like(User::getName, realName)); + } + queryWrapper.lambda().like(StringUtil.isNotBlank(phone), User::getPhone, phone); + } + @Override public void decryptPhone(IPhone user) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java b/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java new file mode 100644 index 00000000..687a3a46 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java @@ -0,0 +1,97 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.util; + +import org.apache.commons.lang3.StringUtils; +import org.springblade.thirdparty.oa.constant.OAConvertConstant; + +import java.util.Collection; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 组织编码工具:上级编码直接拼接固定 3 位分段数字(不足补零,中间不加 "-") + * + * @author Chill + */ +public final class DeptCodeUtil { + + /** + * 分段数字位数 + */ + public static final int SEGMENT_WIDTH = 3; + + private DeptCodeUtil() { + } + + /** + * 生成子级编码:上级编码 + 三位数字 + */ + public static String formatChildCode(String parentCode, int serial) { + return parentCode + String.format("%0" + SEGMENT_WIDTH + "d", serial); + } + + /** + * 是否符合「上级编码 + 三位数字」格式(恰好多出 3 位数字) + */ + public static boolean matchesChildCode(String parentCode, String deptCode) { + if (StringUtils.isBlank(parentCode) || StringUtils.isBlank(deptCode)) { + return false; + } + return deptCode.matches("^" + Pattern.quote(parentCode) + "\\d{" + SEGMENT_WIDTH + "}$"); + } + + /** + * 是否为公司编码(GWWL + 三位数字) + */ + public static boolean isCompanyCode(String deptCode) { + return matchesChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, deptCode); + } + + /** + * 在已有编码中取下一分段序号(从 1 起) + */ + public static int nextSerial(String parentCode, Collection existingCodes) { + if (StringUtils.isBlank(parentCode)) { + return 1; + } + Pattern pattern = Pattern.compile("^" + Pattern.quote(parentCode) + "(\\d{" + SEGMENT_WIDTH + "})$"); + int maxSerial = 0; + if (existingCodes != null) { + for (String code : existingCodes) { + if (StringUtils.isBlank(code)) { + continue; + } + Matcher matcher = pattern.matcher(code.trim()); + if (!matcher.matches()) { + continue; + } + maxSerial = Math.max(maxSerial, Integer.parseInt(matcher.group(1))); + } + } + return maxSerial + 1; + } +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/MpUserWrapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/MpUserWrapper.java new file mode 100644 index 00000000..2022dcd7 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/MpUserWrapper.java @@ -0,0 +1,78 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.wrapper; + +import org.springblade.core.mp.support.BaseEntityWrapper; +import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictCache; +import org.springblade.system.cache.UserCache; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.enums.DictEnum; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.pojo.vo.MpUserVO; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 小程序用户包装类 + * + * @author Chill + */ +public class MpUserWrapper extends BaseEntityWrapper { + + public static MpUserWrapper build() { + return new MpUserWrapper(); + } + + @Override + public MpUserVO entityVO(MpUser mpUser) { + MpUserVO mpUserVO = Objects.requireNonNull(BeanUtil.copyProperties(mpUser, MpUserVO.class)); + // 密码哈希不下发前端 + mpUserVO.setPassword(null); + mpUserVO.setIdentitiesName(resolveIdentitiesName(mpUser.getIdentities())); + if (mpUser.getSex() != null) { + mpUserVO.setSexName(DictCache.getValue(DictEnum.SEX, mpUser.getSex())); + } + mpUserVO.setCreateUserName(UserCache.getUserRealName(mpUser.getCreateUser())); + mpUserVO.setUpdateUserName(UserCache.getUserRealName(mpUser.getUpdateUser())); + return mpUserVO; + } + + private String resolveIdentitiesName(String identities) { + List identityList = MpUserIdentity.split(identities); + if (identityList.isEmpty()) { + return ""; + } + return identityList.stream() + .map(code -> { + MpUserIdentity identity = MpUserIdentity.of(code); + return identity == null ? code : identity.getLabel(); + }) + .filter(Func::isNotBlank) + .collect(Collectors.joining(",")); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java index 7dbfaa32..3630589b 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java @@ -38,6 +38,7 @@ import org.springblade.system.pojo.enums.DictEnum; import org.springblade.system.pojo.vo.UserVO; import java.util.List; +import java.util.Objects; /** * 包装类,返回视图层所需的字段 @@ -81,7 +82,11 @@ public class UserWrapper extends BaseEntityWrapper { userVO.setPostId(StringPool.EMPTY); userVO.setPostName("暂未分配"); } - userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex())); + if (user.getSex() == null || Objects.equals(user.getSex(), -1)) { + userVO.setSexName(""); + } else { + userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex())); + } userVO.setUserTypeName(DictCache.getValue(DictEnum.USER_TYPE, user.getUserType())); return userVO; } diff --git a/blade-service/blade-transport/pom.xml b/blade-service/blade-transport/pom.xml index b9ed58c8..7f7e843b 100644 --- a/blade-service/blade-transport/pom.xml +++ b/blade-service/blade-transport/pom.xml @@ -64,6 +64,15 @@ io.minio minio + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/DeptIdBindingAdvice.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/DeptIdBindingAdvice.java new file mode 100644 index 00000000..61845a9b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/DeptIdBindingAdvice.java @@ -0,0 +1,58 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.config; + +import org.springblade.core.tool.utils.Func; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.InitBinder; + +import java.beans.PropertyEditorSupport; + +/** + * 列表查询 deptId 绑定:用户可能绑定多个组织,前端会传入逗号分隔字符串。 + * 组织筛选项为单选,这里取第一个有效 ID,避免 Long 类型转换失败。 + * + * @author Chill + */ +@ControllerAdvice(basePackages = "org.springblade.transport.controller") +public class DeptIdBindingAdvice { + + @InitBinder + public void initBinder(WebDataBinder binder) { + binder.registerCustomEditor(Long.class, "deptId", new PropertyEditorSupport() { + @Override + public void setAsText(String text) { + if (Func.isBlank(text)) { + setValue(null); + return; + } + setValue(Func.firstLong(text)); + } + }); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java index 08ed5e38..269eb1a5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java @@ -5,28 +5,109 @@ package org.springblade.transport.config; import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.net.URI; +import java.util.concurrent.TimeUnit; + /** * 凭证图片 MinIO 客户端配置。 *

* 连接参数由 Nacos 的 file.storage.minio 配置提供。 - * 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端, - * 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动; - * 此时凭证相关接口会在调用时给出明确提示,而非启动即失败。 + *

    + *
  • {@code endpoint}:建议内网地址;若误配为 https://host/oss,会自动剥离 path 并用拦截器补回
  • + *
  • {@code transform-endpoint}:外网地址(可带 /oss),用于浏览器预签名
  • + *
*/ +@Slf4j @Configuration @ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint") public class VoucherMinioConfig { + private static final String DEFAULT_REGION = "us-east-1"; + @Bean public MinioClient voucherMinioClient( @Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint, @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String accessKey, @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) { - return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build(); + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + if (StringUtil.isNotBlank(pathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址", + origin, pathPrefix); + } + return create(endpoint, accessKey, secretKey); + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(origin) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(pathPrefix)) { + builder.httpClient(buildHttpClient(pathPrefix), true); + } + return builder.build(); + } + + /** + * MinIO SDK 不允许 endpoint 带 path(例如 https://host/oss)。 + */ + public static String resolveClientEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return origin.toString(); + } + + private static String resolvePathPrefix(String endpoint) { + URI uri = URI.create(endpoint.trim()); + String path = uri.getPath(); + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java new file mode 100644 index 00000000..a7b8f544 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java @@ -0,0 +1,103 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +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.tool.api.R; +import org.springblade.transport.pojo.vo.CertAuditDetailVO; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.ICertAuditService; +import org.springblade.transport.support.CertAuditPermissions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 认证审核(管理端) + *

+ * 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。 + * 这与旧的车辆认证审核接口有本质区别:旧接口作用于车辆行并原地覆盖, + * 在「生效值 / 待审值分离」之后语义已不成立,故已弃用。 + */ +@RestController +@AllArgsConstructor +@RequestMapping({"/certification-audit", "/blade-transport/certification-audit"}) +@Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核") +public class CertificationAuditController extends BladeController { + + private final ICertAuditService certAuditService; + + @GetMapping("/pending-page") + @ApiOperationSupport(order = 1) + @Operation(summary = "认证记录列表", description = "按认证对象类型与审核状态分页查询,按提交时间倒序;状态不传时查审核中的记录。列表同时下发该记录司机的姓名与手机号(司机档案当前值)") + @PreAuth(CertAuditPermissions.AUDIT_VIEW) + public R> pendingPage( + @Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true) + @RequestParam String bizType, + @Parameter(description = "展示键模糊匹配:提交时的司机姓名 / 车牌号") + @RequestParam(required = false) String bizKey, + @Parameter(description = "司机姓名模糊匹配:当前姓名,同时兼顾提交时的旧名") + @RequestParam(required = false) String driverName, + @Parameter(description = "司机手机号模糊匹配:支持只输后四位") + @RequestParam(required = false) String driverMobile, + @Parameter(description = "审核状态:0审核中 1已通过 2已驳回 3已作废;不传按0(审核中)处理") + @RequestParam(required = false) Integer status, + Query query) { + return R.data(certAuditService.pendingPage(bizType, bizKey, driverName, driverMobile, status, Condition.getPage(query))); + } + + @GetMapping("/detail") + @ApiOperationSupport(order = 2) + @Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比") + @PreAuth(CertAuditPermissions.AUDIT_VIEW) + public R detail( + @Parameter(description = "认证记录主键", required = true) @RequestParam Long id) { + return R.data(certAuditService.detail(id)); + } + + @PostMapping("/audit") + @ApiOperationSupport(order = 3) + @Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变") + @PreAuth(CertAuditPermissions.AUDIT_ACTION) + public R audit( + @Parameter(description = "认证记录主键", required = true) @RequestParam Long id, + @Parameter(description = "是否通过", required = true) @RequestParam Boolean pass, + @Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) { + return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason)); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java index b6d27107..3ff3eaf1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java @@ -177,8 +177,16 @@ public class ContractManageController extends BladeController { return R.data(contractManageService.expireStats(contractManage)); } - @GetMapping("/export-contract-manage") + @GetMapping("/next-contract-no") @ApiOperationSupport(order = 16) + @Operation(summary = "预览下一合同编号", description = "按合同负责部门编码前缀+年份+三位流水预览") + public R nextContractNo( + @Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId) { + return R.data(contractManageService.nextContractNo(organizationId)); + } + + @GetMapping("/export-contract-manage") + @ApiOperationSupport(order = 17) @Operation(summary = "导出合同管理") public void exportContractManage(ContractManageVO contractManage, @RequestParam(required = false) String ids, HttpServletResponse response) { List list = contractManageService.exportContractManage(contractManage, ids); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java new file mode 100644 index 00000000..d759344b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java @@ -0,0 +1,153 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.tool.api.R; +import org.springblade.transport.ocr.constant.BaiduOcrType; +import org.springblade.transport.ocr.service.IBaiduOcrService; +import org.springblade.transport.pojo.dto.OcrRecognizeRequest; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.vo.BaiduOcrResultVO; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.IDriverCertService; +import org.springblade.transport.support.OcrTypeMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 司机端认证(小程序) + *

+ * 对外路径:{@code /api/blade-transport/driver/auth/**} + * 同时兼容未去前缀直连 {@code /blade-transport/driver/auth/**}。 + *

+ * 提交审核**不影响**该对象当前的可用性:待审内容只落认证记录, + * 审核通过前对外呈现的仍是旧的生效值。 + */ +@RestController +@AllArgsConstructor +@RequestMapping({"/driver/auth", "/blade-transport/driver/auth"}) +@Tag(name = "司机端认证", description = "小程序司机个人认证与车辆认证") +public class DriverAuthController extends BladeController { + + private final IDriverCertService driverCertService; + private final IBaiduOcrService baiduOcrService; + + // ------------------------------------------------------------ 个人认证 + + @PostMapping("/person") + @ApiOperationSupport(order = 1) + @Operation(summary = "提交个人认证", description = "三证(身份证+驾驶证+从业资格证)一起提交,生成待审记录") + public R submitPerson(@RequestBody PersonCertSubmitRequest request) { + return R.data(driverCertService.submitPersonCert(request)); + } + + @GetMapping("/person") + @ApiOperationSupport(order = 2) + @Operation(summary = "个人认证生效值", description = "返回当前生效快照的内容;从未认证通过时 data 为 null") + public R person() { + return R.data(driverCertService.getEffectivePersonCert()); + } + + // ------------------------------------------------------------ 车辆认证 + + @PostMapping("/vehicle") + @ApiOperationSupport(order = 3) + @Operation(summary = "提交车辆认证", description = "生成待审记录;不改变该车已生效的认证内容") + public R submitVehicle(@RequestBody VehicleCertSubmitRequest request) { + return R.data(driverCertService.submitVehicleCert(request)); + } + + @PutMapping("/vehicle") + @ApiOperationSupport(order = 4) + @Operation(summary = "重新认证车辆", description = "与提交同一语义,语义上用于已有车辆重新认证") + public R updateVehicle(@RequestBody VehicleCertSubmitRequest request) { + return R.data(driverCertService.submitVehicleCert(request)); + } + + @DeleteMapping("/vehicle/{id}") + @ApiOperationSupport(order = 5) + @Operation(summary = "删除车辆认证", description = "对车辆做逻辑删除;认证记录保留,不物理删除") + public R deleteVehicle(@PathVariable Long id) { + return R.status(driverCertService.deleteVehicleCert(id)); + } + + @GetMapping("/vehicle/effective") + @ApiOperationSupport(order = 5) + @Operation(summary = "车辆认证生效值", description = "返回该车当前生效快照的内容") + public R vehicleEffective(@RequestParam Long vehicleId) { + return R.data(driverCertService.getEffectiveVehicleCert(vehicleId)); + } + + // ------------------------------------------------------------ 认证记录 + + @GetMapping("/record/list") + @ApiOperationSupport(order = 6) + @Operation(summary = "认证记录列表", description = "按提交时间倒序;只能查本人或本人绑定车辆的记录") + public R> recordList(@RequestParam String bizType, + @RequestParam Long bizId) { + return R.data(driverCertService.listRecords(bizType, bizId)); + } + + @GetMapping("/record/detail") + @ApiOperationSupport(order = 7) + @Operation(summary = "认证记录详情", description = "内容取自该次提交的完整快照") + public R recordDetail(@RequestParam Long id) { + return R.data(driverCertService.getRecord(id)); + } + + // ------------------------------------------------------------ OCR + + @PostMapping("/ocr") + @ApiOperationSupport(order = 8) + @Operation(summary = "证件OCR识别", + description = "按小程序既有契约收 {imageUrl, type, side?},转调百度OCR。" + + "type 用小程序命名:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证") + public R ocr(@RequestBody OcrRecognizeRequest request) { + // 两侧对「驾驶证 / 行驶证」的用词正好相反,故必须经显式映射,不能把入参直接透传下游 + BaiduOcrType ocrType = OcrTypeMapping.toBaiduType(request.getType()); + // 未指定时传 null,不要在这里补 front:道路运输证没有正副面参数, + // 补了会变成「传了 side 但该证件不支持」,让这条链路恒失败。 + // 「默认取正面」由知晓证件类型的一侧(OCR 服务)决定。 + String side = OcrTypeMapping.normalizeSide(request.getSide()); + return R.data(baiduOcrService.recognizeUrl(ocrType, side, request.getImageUrl())); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index 36050850..f966601d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -38,6 +38,7 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverWaybillService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -64,14 +65,14 @@ public class DriverWaybillController extends BladeController { @GetMapping("/current-task") @ApiOperationSupport(order = 1) - @Operation(summary = "首页:当前运输中任务", description = "当前司机绑定车牌下 businessStatus=running 的最新一条运单") + @Operation(summary = "首页:当前运输中任务", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=running 的最新一条运单") public R currentTask() { return R.data(driverWaybillService.currentTask()); } @GetMapping("/pending-preview") @ApiOperationSupport(order = 2) - @Operation(summary = "首页:待接运单预览", description = "当前司机绑定车牌下 businessStatus=pending 的预览列表与总数") + @Operation(summary = "首页:待接运单预览", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=pending 的预览列表与总数") public R pendingPreview( @Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) { return R.data(driverWaybillService.pendingPreview(size)); @@ -79,14 +80,14 @@ public class DriverWaybillController extends BladeController { @GetMapping("/counts") @ApiOperationSupport(order = 3) - @Operation(summary = "运单 Tab 统计", description = "仅统计当前司机绑定车牌对应的运单:全部 / 待接单 / 进行中 / 已完成") + @Operation(summary = "运单 Tab 统计", description = "仅统计当前用户手机号匹配运单 driverPhone 的运单:全部 / 待接单 / 进行中 / 已完成") public R counts() { return R.data(driverWaybillService.tabCounts()); } @GetMapping("/page") @ApiOperationSupport(order = 4) - @Operation(summary = "运单分页列表", description = "仅返回当前司机绑定车牌(driving_vehicle)匹配运单 vehicleNo/trailerVehicleNo 的数据;status:空=全部,0待接单,1运输中,2已完成") + @Operation(summary = "运单分页列表", description = "仅返回运单 driverPhone 等于当前用户手机号的数据;status:空=全部,0待接单,1运输中,2已完成") public R> page( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, @@ -104,6 +105,13 @@ public class DriverWaybillController extends BladeController { return R.data(driverWaybillService.detail(id)); } + @PostMapping("/track") + @ApiOperationSupport(order = 11) + @Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)") + public R track(@Parameter(description = "运单ID", required = true) @RequestParam Long id) { + return R.data(driverWaybillService.track(id)); + } + @PostMapping("/accept") @ApiOperationSupport(order = 6) @Operation(summary = "司机确认接单", description = "过程配置接单为「是」时,司机确认接单后运单进入进行中") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index 2fc52aa9..855f37ec 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -30,11 +30,14 @@ import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; 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.tool.api.R; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.support.ExceptionDisposalPermissions; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -102,4 +105,13 @@ public class ExceptionDisposalController extends BladeController { return R.success("批量完成成功"); } + @PostMapping("/update") + @PreAuth(ExceptionDisposalPermissions.EDIT) + @ApiOperationSupport(order = 7) + @Operation(summary = "编辑异常处置", description = "运管员订正上报内容;不改动处置状态") + public R update(@RequestBody ExceptionDisposalUpdateRequest request) { + exceptionDisposalService.update(request); + return R.success("编辑成功"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index 119cf5b9..0b7dda41 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -30,6 +30,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.AdminDriverOptionVO; import org.springblade.transport.pojo.vo.AdminHomeStatsVO; @@ -37,7 +38,9 @@ import org.springblade.transport.pojo.vo.AdminHomeVO; import org.springblade.transport.pojo.vo.AdminVehicleOptionVO; import org.springblade.transport.pojo.vo.AdminWaybillCardVO; import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; +import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.service.IManageWaybillService; +import org.springblade.transport.service.IWaybillService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -62,6 +65,7 @@ import java.util.List; public class ManageWaybillController extends BladeController { private final IManageWaybillService manageWaybillService; + private final IWaybillService waybillService; @GetMapping("/stats") @ApiOperationSupport(order = 1) @@ -72,7 +76,7 @@ public class ManageWaybillController extends BladeController { @GetMapping("/home") @ApiOperationSupport(order = 2) - @Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名") + @Operation(summary = "首页聚合", description = "统计 + 异常/风险/重新派单角标 + 待处理事项(异常处置≠已完成,或司机已拒单待重新派单)+ 当前用户名") public R home() { return R.data(manageWaybillService.home()); } @@ -140,4 +144,19 @@ public class ManageWaybillController extends BladeController { return R.data(manageWaybillService.searchVehicles(keyword)); } + @GetMapping("/punch-records") + @ApiOperationSupport(order = 9) + @Operation(summary = "打卡记录", description = "小程序调度端:仅需登录态;按过程配置返回节点/在途打卡流水") + public R punchRecords( + @Parameter(description = "运单ID", required = true) @RequestParam Long waybillId) { + return R.data(waybillService.listPunchRecords(waybillId)); + } + + @PostMapping("/punch-record/save") + @ApiOperationSupport(order = 10) + @Operation(summary = "编辑打卡记录", description = "小程序调度端:补录/订正/取消打卡,仅需登录态") + public R savePunchRecord(@RequestBody WaybillPunchRecordSaveRequest request) { + return R.status(waybillService.savePunchRecord(request)); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java index c21f3bf6..37596eb4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java @@ -35,6 +35,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.excel.util.ExcelUtil; import org.springblade.core.mp.support.Condition; @@ -67,6 +68,7 @@ import java.util.List; */ @RestController @AllArgsConstructor +@Slf4j @PreAuth(menu = "transport_vehicle") @RequestMapping("/transport-vehicle") @Tag(name = "车辆管理", description = "车辆管理") @@ -132,11 +134,27 @@ public class TransportVehicleController extends BladeController { return R.status(transportVehicleService.changeStatus(id, status)); } + /** + * 审核车辆认证(已弃用) + * + * @deprecated 本接口作用于**车辆行**并原地覆盖认证状态,在「生效值 / 待审值分离」 + * 之后语义已不成立:一次审核应对应**一条认证记录**,且通过才建立生效快照、 + * 驳回不得影响既得认证。请改用 + * {@code POST /blade-transport/certification-audit/audit}。 + *

+ * 保留而非删除,是因为它可能仍被小程序历史版本或其它系统调用; + * 调用时记录告警日志。它只写展示缓存,**不再影响可用性判定** + * (可用性只看 {@code effective_cert_record_id})。 + */ + @Deprecated @PostMapping("/audit-certification") @ApiOperationSupport(order = 6) - @Operation(summary = "审核车辆认证") + @Operation(summary = "审核车辆认证(已弃用)", + description = "已弃用:作用于车辆行且原地覆盖。请改用 /blade-transport/certification-audit/audit") public R auditCertification(@RequestParam Long id, @RequestParam Integer certificationStatus, @RequestParam(required = false) String rejectReason) { + log.warn("[认证审核] 调用了已弃用接口 audit-certification,vehicleId={}, status={};" + + "请改用 /blade-transport/certification-audit/audit", id, certificationStatus); return R.status(transportVehicleService.auditCertification(id, certificationStatus, rejectReason)); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java index b7e1ef2c..69f53eb0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java @@ -29,10 +29,16 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; +/** + * 对外路径:{@code /blade-transport/voucher-manage/**} + *

+ * 网关 StripPrefix 后落入 {@code /voucher-manage/**}; + * 同时兼容未去前缀直连 {@code /blade-transport/voucher-manage/**},避免 404 落到静态资源。 + */ @RestController @AllArgsConstructor @PreAuth(menu = "voucher_manage") -@RequestMapping("/voucher-manage") +@RequestMapping({"/voucher-manage", "/blade-transport/voucher-manage"}) @Tag(name = "凭证管理", description = "执行凭证批次管理") public class VoucherManageController extends BladeController { private final IVoucherManageService voucherManageService; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java index e7bc1194..82d39780 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java @@ -23,6 +23,7 @@ package org.springblade.transport.controller; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; import cn.idev.excel.write.handler.SheetWriteHandler; import cn.idev.excel.write.metadata.holder.WriteSheetHolder; @@ -43,6 +44,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.UserCache; import org.springblade.transport.excel.WaybillExcel; import org.springblade.transport.excel.WaybillImportBatchExcel; import org.springblade.transport.pojo.entity.ContractManage; @@ -50,6 +52,7 @@ import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.ProjectApply; import org.springblade.transport.pojo.entity.TransportPlan; import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillImportBatch; import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.LoadingManageVO; @@ -60,6 +63,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.dto.WaybillMileageRequest; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.ICustomerArchiveService; import org.springblade.transport.service.IProjectApplyService; @@ -76,8 +80,11 @@ import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; /** * 运单管理 控制器 @@ -113,6 +120,13 @@ public class WaybillController extends BladeController { return R.data(waybillService.listPunchRecords(waybillId)); } + @PostMapping("/punch-record/save") + @ApiOperationSupport(order = 2) + @Operation(summary = "编辑打卡记录", description = "按过程配置补录/修正节点或在途打卡;punched=false 表示取消打卡,清除该节点打卡流水") + public R savePunchRecord(@RequestBody WaybillPunchRecordSaveRequest request) { + return R.status(waybillService.savePunchRecord(request)); + } + @PostMapping("/locate") @ApiOperationSupport(order = 1) @Operation(summary = "车辆实时定位", description = "按运单绑定车牌调用 LBS_LOCATE") @@ -151,10 +165,22 @@ public class WaybillController extends BladeController { .map(contract -> option(contract.getId(), "contractName", contract.getContractName(), "projectId", contract.getProjectId())) .toList()); options.put("carriers", customerArchiveService.list().stream() - .filter(customer -> "承运商".equals(customer.getCustomerType()) && Integer.valueOf(1).equals(customer.getStatus())) + .filter(customer -> isCarrierCustomer(customer) && Integer.valueOf(1).equals(customer.getStatus())) .map(customer -> option(customer.getId(), "name", customer.getFullName(), null, null)) .toList()); - options.put("creators", new ArrayList<>()); + // 创建人选项取已有导入批次的创建人,保证筛选项与列表数据可对应。 + Set creatorIds = new LinkedHashSet<>(); + waybillImportBatchService.list(Wrappers.lambdaQuery() + .select(WaybillImportBatch::getCreateUser) + .eq(WaybillImportBatch::getIsDeleted, 0) + .isNotNull(WaybillImportBatch::getCreateUser)) + .stream() + .map(WaybillImportBatch::getCreateUser) + .filter(Objects::nonNull) + .forEach(creatorIds::add); + options.put("creators", creatorIds.stream() + .map(userId -> option(userId, "name", UserCache.getUserRealName(userId), null, null)) + .toList()); options.put("plans", transportPlanService.list().stream() .map(plan -> option(plan.getId(), "planName", plan.getPlanName(), "projectId", plan.getProjectId())) .toList()); @@ -364,6 +390,12 @@ public class WaybillController extends BladeController { return option; } + /** 客商类型支持多选(逗号分隔),包含「承运商」即视为承运商。 */ + private boolean isCarrierCustomer(CustomerArchive customer) { + String type = Func.toStr(customer.getCustomerType()).replace(",", ","); + return Func.toStrList(type).stream().anyMatch("承运商"::equals); + } + private static final class TextColumnStyleHandler implements SheetWriteHandler { private final int[] columnIndexes; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java index fe30218e..2e9fdbc0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java @@ -56,7 +56,7 @@ public class ContractManageExcel implements Serializable { private String contractName; @ExcelProperty("所属项目") private String projectName; - @ExcelProperty("所属组织") + @ExcelProperty("合同负责部门") private String organizationName; @ExcelProperty("合同类别") private String contractCategory; @@ -76,6 +76,10 @@ public class ContractManageExcel implements Serializable { private LocalDate temporaryEndDate; @ExcelProperty("经办人") private String handlerUserName; + @ExcelProperty("签订日期") + private LocalDate signDate; + @ExcelProperty("申请日期") + private LocalDate applyDate; @ExcelProperty("合同阶段") private String contractStage; @ExcelProperty("审核状态") @@ -105,7 +109,9 @@ public class ContractManageExcel implements Serializable { private String currentNode; @ExcelProperty("当前处理人") private String currentProcessor; - @ExcelProperty("备注") + @ExcelProperty("合同主要内容") + private String mainContent; + @ExcelProperty("其他事项说明") private String remark; @ExcelProperty("创建人") private String createUserName; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml index 45ad067b..89e87067 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml @@ -46,6 +46,12 @@ + + + + + + @@ -70,6 +76,9 @@ posts, id_card_front, id_card_back, + id_card_valid_start, + id_card_valid_end, + id_card_long_term, head_photo, driving_type, driving_license_no, @@ -91,7 +100,10 @@ organization_name, emergency_contact_name, emergency_contact_mobile, - remark + remark, + certification_status, + certification_reject_reason, + effective_cert_record_id + SELECT + r.id, + r.tenant_id, + r.biz_type, + r.biz_id, + r.biz_key, + r.status, + r.reject_reason, + r.submit_time, + r.submit_user, + r.audit_time, + r.audit_user, + r.create_user, + r.create_dept, + r.create_time, + r.update_user, + r.update_time, + r.is_deleted, + d.driver_name AS driver_name, + d.mobile AS driver_mobile + FROM + blade_transport_cert_record r + LEFT JOIN blade_transport_driver d + ON d.is_deleted = 0 + AND d.id = CASE + WHEN r.biz_type = 'driver' THEN r.biz_id + ELSE ( + SELECT dr.id + FROM blade_transport_driver dr + WHERE dr.is_deleted = 0 + AND dr.user_id = r.submit_user + ORDER BY dr.update_time DESC, dr.id DESC + LIMIT 1 + ) + END + WHERE + r.is_deleted = 0 + AND r.biz_type = #{bizType} + AND r.status = #{status} + + AND r.biz_key LIKE CONCAT('%', #{bizKey}, '%') + + + + AND ( + d.driver_name LIKE CONCAT('%', #{driverName}, '%') + OR r.biz_key LIKE CONCAT('%', #{driverName}, '%') + ) + + + AND d.mobile LIKE CONCAT('%', #{driverMobile}, '%') + + ORDER BY + r.submit_time DESC + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml index c7411ab8..0faafb78 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml @@ -48,6 +48,7 @@ + @@ -96,6 +97,7 @@ remark , certification_status , certification_reject_reason + , effective_cert_record_id diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java index 9443faa8..2e23f078 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java @@ -105,7 +105,7 @@ public class MkProcessMessageService { bizType, processId, loginName, JSON.toJSONString(processInfo)); if (isProcessFinished(processInfo)) { applyProcessResult(handler, bizType, bizIdText, processId, formValues, true); - } else if (isProcessRejected(processInfo)) { + } else if (MkProcessNodeHelper.isProcessRejected(processInfo)) { applyProcessResult(handler, bizType, bizIdText, processId, formValues, false); } else if (StringUtil.isNotBlank(bizIdText)) { try { @@ -123,19 +123,6 @@ public class MkProcessMessageService { return "30".equals(firstText(asMap(processInfo), "fdProcessStatus")); } - private static boolean isProcessRejected(Object processInfo) { - Map info = asMap(processInfo); - if (!"20".equals(firstText(info, "fdProcessStatus"))) { - return false; - } - Object historyHandlerInfos = asMap(info.get("fdTaskInfo")).get("historyHandlerInfos"); - if (!(historyHandlerInfos instanceof List historyList) || historyList.isEmpty()) { - return false; - } - Object lastHandler = historyList.get(historyList.size() - 1); - return "N6".equals(firstText(asMap(lastHandler), "nodeNumber")); - } - private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java index c665e58f..6004bbb0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java @@ -11,20 +11,47 @@ import java.util.Map; import java.util.stream.Collectors; /** - * 解析 MK 流程实例详情中的当前节点、当前处理人 + * 解析 MK 流程实例详情中的当前节点、当前处理人,以及流程是否驳回到起草节点。 */ public final class MkProcessNodeHelper { + /** + * 起草节点编号。审批节点编号随流程模板变化,不能用来判断驳回。 + */ + private static final String DRAFT_NODE_NUMBER = "N2"; + + private static final List CURRENT_NODE_KEYS = List.of("handlerInfos", "drafterInfos", "adminInfos"); + private MkProcessNodeHelper() { } + /** + * 流程未结束,且当前待办已经回到起草节点时,才视为审核驳回。 + * 中间审批节点通过后流程状态仍是 20,不能把历史节点编号当成驳回。 + */ + public static boolean isProcessRejected(Object processInfo) { + Map info = asMap(processInfo); + String processStatus = text(info.get("fdProcessStatus")); + if (!"11".equals(processStatus) && !"20".equals(processStatus)) { + return false; + } + return hasActiveDraftNode(asMap(info.get("fdTaskInfo"))); + } + public static String currentProcessor(Object processInfo) { return joinDistinct(extractTexts(asMap(processInfo).get("currentHandlers"), "fdName", "name"), "、"); } public static String currentNode(Object processInfo) { Map taskInfo = asMap(asMap(processInfo).get("fdTaskInfo")); - return joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、"); + List nodeNames = new ArrayList<>(); + for (String key : CURRENT_NODE_KEYS) { + nodeNames.addAll(extractActiveNodeNames(taskInfo.get(key))); + } + if (nodeNames.isEmpty()) { + nodeNames.addAll(extractTexts(taskInfo.get("handlerInfos"), "nodeName")); + } + return joinDistinct(nodeNames, "、"); } public static String text(String value, String fallback) { @@ -52,6 +79,59 @@ public final class MkProcessNodeHelper { } } + private static boolean hasActiveDraftNode(Map taskInfo) { + for (String key : CURRENT_NODE_KEYS) { + Object nodes = taskInfo.get(key); + if (!(nodes instanceof Collection collection)) { + continue; + } + for (Object item : collection) { + if (isActiveDraftNode(asMap(item))) { + return true; + } + } + } + return false; + } + + private static boolean isActiveDraftNode(Map node) { + if (!"20".equals(text(node.get("status")))) { + return false; + } + String nodeNumber = text(node.get("nodeNumber")); + if (Func.isEmpty(nodeNumber)) { + nodeNumber = text(node.get("nodeId")); + } + if (DRAFT_NODE_NUMBER.equals(nodeNumber)) { + return true; + } + String activityType = text(node.get("activityType")); + return "draft".equalsIgnoreCase(activityType) || "draftTask".equalsIgnoreCase(activityType); + } + + private static List extractActiveNodeNames(Object listObj) { + List result = new ArrayList<>(); + if (!(listObj instanceof Collection collection)) { + return result; + } + for (Object item : collection) { + Map node = asMap(item); + if (!"20".equals(text(node.get("status")))) { + continue; + } + String nodeName = text(node.get("nodeName")); + if (Func.isNotEmpty(nodeName)) { + result.add(nodeName); + } + } + return result; + } + + private static String text(Object value) { + String text = Func.toStr(value, "").trim(); + return "null".equalsIgnoreCase(text) ? "" : text; + } + private static List extractTexts(Object listObj, String... keys) { List result = new ArrayList<>(); if (!(listObj instanceof Collection collection) || keys == null) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java index 3a606276..2ddbde55 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java @@ -36,6 +36,7 @@ import org.springblade.transport.ocr.config.BaiduOcrProperties; import org.springblade.transport.ocr.constant.BaiduOcrType; import org.springblade.transport.ocr.service.IBaiduOcrService; import org.springblade.transport.pojo.vo.BaiduOcrResultVO; +import org.springblade.transport.support.OcrFieldSupport; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; @@ -143,6 +144,9 @@ public class BaiduOcrServiceImpl implements IBaiduOcrService { response.setType(type.name()); response.setSide(normalizedSide); response.setResult(objectMapper.convertValue(result, RESULT_TYPE)); + // 同时下发归一化字段:调用方不该去认识百度那套中文键名, + // 且键名对不上时前端只会「静默什么都不填」,是最难查的一类问题 + response.setFields(OcrFieldSupport.extract(type, normalizedSide, result)); return response; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java new file mode 100644 index 00000000..89af4d43 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java @@ -0,0 +1,83 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.CertAuditDetailVO; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +/** + * 认证审核服务(管理端) + *

+ * 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。 + * 通过使该记录成为生效快照;驳回只终结该记录,生效快照保持不变。 + * + * @author Chill + */ +public interface ICertAuditService { + + /** + * 认证记录分页(按认证对象类型与审核状态) + *

+ * 审核页按「待审核 / 已通过 / 已驳回」分页签展示,故状态可传; + * 不传时按待审核处理,兼容只想要待审队列的调用方。 + *

+ * 列表同时下发该记录的司机姓名与手机号,取的是司机档案的**当前**值 + * (认证记录里的姓名是提交时的历史值,手机号则从来不在快照里)。 + * 解析不到司机档案的记录,两个字段为空,不回落成提交时的值—— + * 那会让「同一个人的多条记录显示成不同的人」。 + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空 + * @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空 + * @param driverMobile 司机手机号模糊匹配,可为空 + * @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中) + * @param page 分页参数(由 Controller 用 Condition.getPage 构造) + * @return 认证记录分页 + */ + IPage pendingPage(String bizType, String bizKey, String driverName, String driverMobile, + Integer status, IPage page); + + /** + * 审核详情:同时返回待审快照与当前生效值,供前端做 diff + * + * @param recordId 认证记录主键 + * @return 审核详情 + */ + CertAuditDetailVO detail(Long recordId); + + /** + * 审核一条认证记录 + * + * @param recordId 认证记录主键 + * @param pass true 通过,false 驳回 + * @param rejectReason 驳回原因;驳回时必填,≤200 字 + * @return 是否成功 + */ + boolean audit(Long recordId, boolean pass, String rejectReason); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java index 71fef30c..e43ebcc7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java @@ -64,4 +64,12 @@ public interface IContractManageService extends BaseService { Map expireStats(ContractManageVO contractManage); List exportContractManage(ContractManageVO contractManage, String ids); + /** + * 预览下一合同编号(按合同负责部门前缀 + 年份 + 三位流水) + * + * @param organizationId 合同负责部门ID + * @return 预览编号 + */ + String nextContractNo(Long organizationId); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java index 5f357a44..d7c7d34a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java @@ -22,6 +22,7 @@ */ package org.springblade.transport.service; +import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; import org.springblade.transport.pojo.vo.DriverVO; @@ -44,4 +45,15 @@ public interface IDriverAppService { */ List myVehicles(); + /** + * 当前登录用户对应的司机**实体** + *

+ * 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle}, + * 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配, + * 匹配不到再按 {@code user_id} 兜底。 + * + * @return 司机实体;未登录或查不到时返回 null + */ + Driver currentDriverEntity(); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverCertService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverCertService.java new file mode 100644 index 00000000..7a76e5ff --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverCertService.java @@ -0,0 +1,111 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +import java.util.List; + +/** + * 司机端认证服务(小程序) + *

+ * 认证对象是「一事一议」的:司机的个人认证与每一辆车的车辆认证各自独立成立、 + * 独立审核、独立留痕。本服务只负责司机端能做什么,管理端审核在审核服务里。 + * + * @author Chill + */ +public interface IDriverCertService { + + /** + * 提交个人认证(全量:身份证 + 驾驶证 + 从业资格证,三证一起审) + *

+ * 生成一条待审记录,并把本人前一条待审记录置为已作废。 + * **不修改生效快照**:已认证的司机在本次审核通过前仍按旧生效值认定。 + * + * @param request 个人认证内容 + * @return 新生成的认证记录主键 + */ + Long submitPersonCert(PersonCertSubmitRequest request); + + /** + * 读取本人当前生效的个人认证内容(供个人认证卡片展示) + * + * @return 生效快照内容;从未认证通过时返回 null + */ + PersonCertSubmitRequest getEffectivePersonCert(); + + /** + * 提交车辆认证(新增或重新认证) + *

+ * 生成一条待审记录,并把该车前一条待审记录置为已作废。 + * **不修改生效快照**:已认证的车辆在本次审核通过前仍按旧生效值认定, + * 因此提交审核不会影响车辆正常接单。 + * + * @param request 车辆认证内容 + * @return 新生成的认证记录主键 + */ + Long submitVehicleCert(VehicleCertSubmitRequest request); + + /** + * 删除车辆认证(按逻辑删除处理) + *

+ * 只把车辆标记为已删除,**认证记录一律保留**——记录是不可变快照, + * 也是「曾通过即永久可用」这一判定的依据,物理删除会让历史无从追溯。 + * 只能删除本人绑定的车辆,越权拒绝。 + * + * @param vehicleId 车辆主键 + * @return 是否成功 + */ + boolean deleteVehicleCert(Long vehicleId); + + /** + * 读取某辆车当前生效的认证内容 + * + * @param vehicleId 车辆主键 + * @return 生效快照内容;从未认证通过时返回 null + */ + VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId); + + /** + * 查询某认证对象的认证记录列表(按提交时间倒序) + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizId 认证对象主键 + * @return 认证记录列表 + */ + List listRecords(String bizType, Long bizId); + + /** + * 查询单条认证记录详情 + * + * @param recordId 认证记录主键 + * @return 认证记录;不存在或无权查看时抛业务异常 + */ + TransportCertRecordVO getRecord(Long recordId); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index be434257..0d9d3f15 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -30,6 +30,9 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; + +import java.util.Map; /** * 司机端运单服务(小程序首页 / 列表) @@ -68,6 +71,19 @@ public interface IDriverWaybillService { */ DriverWaybillCardVO detail(Long id); + /** + * 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。 + *

+ * 起止日期优先级: + *

    + *
  1. 有打卡且多于 1 条:首条打卡日 → 末条打卡日
  2. + *
  3. 有打卡且仅 1 条:首条打卡日 → 今天
  4. + *
  5. 无打卡有计划开始日:计划开始日 → 今天
  6. + *
  7. 无打卡无计划:3 天前 → 今天
  8. + *
+ */ + WaybillTrackVO track(Long id); + /** * 按运单ID组装详情打卡数据(punchNodes / enrouteRecords),不校验当前登录人是否为该司机。 * 供调度端 manage/detail 复用。 @@ -99,4 +115,26 @@ public interface IDriverWaybillService { */ boolean complete(Long id); + /** + * 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。 + *

+ * 运单优先用项目启用中的过程配置,不可用时回退运单自身快照。 + */ + Map findPunchNode(Long waybillId, String nodeCode); + + /** + * 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。 + *

+ * 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。 + */ + boolean isNodePunched(Long waybillId, String nodeCode); + + /** + * 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。 + *

+ * 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案, + * 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。 + */ + void assertCurrentDriverOwnsWaybill(Long waybillId); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index b04ee264..d23ca19c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -25,6 +25,7 @@ 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.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -44,6 +45,11 @@ public interface IExceptionDisposalService extends BaseService + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.core.mp.base.BaseService; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +import java.util.List; + +/** + * 认证记录 服务类 + *

+ * 只承载认证记录本身的读写与数据模型约束的守护;提交、审核、可用性门槛等业务链路 + * 由各自的业务服务实现。 + * + * @author Chill + */ +public interface ITransportCertRecordService extends BaseService { + + /** + * 某认证对象的认证记录列表,按提交时间倒序 + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizId 认证对象主键 + * @return 认证记录列表 + */ + List selectRecordList(String bizType, Long bizId); + + /** + * 取某认证对象当前的待审快照(至多一条) + * + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizId 认证对象主键 + * @return 待审快照,无则返回 null + */ + TransportCertRecord selectPendingRecord(String bizType, Long bizId); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java index 7f3af7dc..8a5abe66 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java @@ -33,6 +33,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.dto.WaybillMileageRequest; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import java.util.List; @@ -59,6 +60,16 @@ public interface IWaybillService extends BaseService { */ WaybillPunchRecordsVO listPunchRecords(Long waybillId); + /** + * 管理端:编辑时间轴上的单条打卡记录(含打卡状态) + *

+ * punched=false 时清除该节点(在途节点为该运单全部在途流水)的打卡流水。 + * + * @param request 打卡记录编辑请求 + * @return 是否成功 + */ + boolean savePunchRecord(WaybillPunchRecordSaveRequest request); + /** * 运单车辆实时定位(按运单绑定车牌调用 LBS) * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java new file mode 100644 index 00000000..09a6ece6 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -0,0 +1,516 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.alibaba.fastjson.JSON; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.CertAuditDetailVO; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.ICertAuditService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.CertificationRuleSupport; +import org.springblade.transport.wrapper.TransportCertRecordWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +/** + * 认证审核服务实现 + *

+ * 审核作用于一条记录,两条路径的差别在于是否切换生效快照: + *

    + *
  • 通过:记录转已通过,并把该对象的生效快照指针切到它;
  • + *
  • 驳回:记录转已驳回并留原因,生效快照指针一个字都不动。
  • + *
+ * 因此「已认证的车被驳回」不会让它掉出可用池,只是这次提交没被采纳。 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class CertAuditServiceImpl implements ICertAuditService { + + private final ITransportCertRecordService certRecordService; + private final TransportCertRecordMapper certRecordMapper; + private final ITransportVehicleService transportVehicleService; + private final IDriverService driverService; + + @Override + public IPage pendingPage(String bizType, String bizKey, String driverName, String driverMobile, + Integer status, IPage page) { + if (!CertificationRuleSupport.isValidBizType(bizType)) { + throw new ServiceException("认证对象类型不正确"); + } + // 未传状态时按「审核中」处理:审核页默认停在待审页签, + // 只关心待审队列的调用方也不必额外传参 + Integer queryStatus = status == null ? TransportCertRecord.STATUS_PENDING : status; + + // 司机姓名与手机号、以及按它们的筛选,都要在**分页之前**由 SQL 完成: + // 在 Java 层补字段再过滤会变成「先分页再过滤」,条数与总数都不准。 + List records = certRecordMapper.selectAuditPage(page, bizType, queryStatus, + blankToNull(bizKey), blankToNull(driverName), blankToNull(driverMobile)); + TransportCertRecordWrapper wrapper = TransportCertRecordWrapper.build(); + records.forEach(wrapper::fillDisplayFields); + return page.setRecords(records); + } + + /** + * 搜索项的空串与纯空白一律当作「没填」 + *

+ * 否则 {@code " "} 会变成 {@code LIKE '% %'},把「没输入」当成一个真实筛选条件。 + * + * @param value 原始搜索值 + * @return 去空白后的值;为空时返回 null + */ + private String blankToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @Override + public CertAuditDetailVO detail(Long recordId) { + TransportCertRecord record = requireRecord(recordId); + Long effectiveId = effectiveRecordIdOf(record.getBizType(), record.getBizId()); + + CertAuditDetailVO detail = new CertAuditDetailVO(); + detail.setId(record.getId()); + detail.setBizType(record.getBizType()); + detail.setBizId(record.getBizId()); + detail.setBizKey(record.getBizKey()); + detail.setStatus(record.getStatus()); + detail.setStatusText(CertificationRuleSupport.statusText(record.getStatus())); + detail.setSubmitTime(record.getSubmitTime()); + detail.setSubmitUser(record.getSubmitUser()); + // 提交人来自小程序用户;审核人来自后台系统用户 + detail.setSubmitUserName(UserDisplayCache.getUserRealName(record.getSubmitUser())); + detail.setAuditUser(record.getAuditUser()); + detail.setAuditTime(record.getAuditTime()); + detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser())); + detail.setPendingSnapshotJson(record.getSnapshotJson()); + + // 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理 + detail.setEffectiveRecordId(effectiveId); + detail.setCertified(CertificationRuleSupport.isCertified(effectiveId)); + if (Func.isNotEmpty(effectiveId)) { + TransportCertRecord effective = certRecordService.getById(effectiveId); + detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson()); + } + return detail; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean audit(Long recordId, boolean pass, String rejectReason) { + TransportCertRecord record = requireRecord(recordId); + + // 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」, + // 生效值就凭空消失了——这正是必须挡住的原因。 + if (!CertificationRuleSupport.isAuditable(record)) { + throw new ServiceException("该认证记录已是" + + CertificationRuleSupport.statusText(record.getStatus()) + "状态,不可再次审核"); + } + if (!CertificationRuleSupport.isRejectReasonValid(pass, rejectReason)) { + throw new ServiceException("请输入认证驳回原因(不超过" + + TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)"); + } + + Long auditor = AuthUtil.getUserId(); + LocalDateTime auditTime = LocalDateTime.now(); + + // 1) 记录本身:状态 + 审核留痕(审核人、审核时间写在这条记录上,不依赖会被覆盖的审计字段) + TransportCertRecord patch = new TransportCertRecord(); + patch.setId(record.getId()); + patch.setStatus(pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED); + patch.setRejectReason(pass + ? CertificationRuleSupport.reasonForApprove() + : CertificationRuleSupport.reasonForReject(rejectReason)); + patch.setAuditUser(auditor); + patch.setAuditTime(auditTime); + certRecordService.updateById(patch); + + // 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE, + // 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。 + updateCertTarget(record, pass, rejectReason); + return true; + } + + /** + * 更新认证对象的展示缓存,并在通过时把生效快照指针切到这条记录 + *

+ * 只发**一次** UPDATE: + *

    + *
  • 通过 —— 状态=已通过、清空驳回原因、指针指向本条(切换而非追加,故仍至多一条生效快照);
  • + *
  • 驳回 —— 状态=已驳回、写入原因,**不带指针字段**,生效快照一个字都不动。
  • + *
+ * 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。 + * + * @param record 被审核的记录 + * @param pass 是否通过 + * @param rejectReason 驳回原因 + */ + private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) { + boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType()); + Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED; + // 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列 + Long newEffectiveId = pass ? record.getId() : null; + + if (vehicleTarget) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(record.getBizId()); + vehicle.setCertificationStatus(status); + if (pass) { + vehicle.setEffectiveCertRecordId(newEffectiveId); + // 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。 + // 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值—— + // 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。 + // 与指针合并成同一次 UPDATE。 + applySnapshotToVehicle(vehicle, record.getSnapshotJson()); + } else { + vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); + } + requireTargetUpdated(transportVehicleService.updateById(vehicle), "车辆", record.getBizId()); + if (pass) { + clearVehicleRejectReason(record.getBizId()); + } + return; + } + Driver driver = new Driver(); + driver.setId(record.getBizId()); + driver.setCertificationStatus(status); + // 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS, + // 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL, + // 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。 + copyDrivingVehicle(record.getBizId(), driver); + if (pass) { + driver.setEffectiveCertRecordId(newEffectiveId); + // 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。 + // 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空), + // 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。 + // 与指针合并成同一次 UPDATE。 + applySnapshotToDriver(driver, record.getSnapshotJson()); + } else { + driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); + } + requireTargetUpdated(driverService.updateById(driver), "司机", record.getBizId()); + if (pass) { + clearDriverRejectReason(record.getBizId()); + } + } + + /** + * 清空车辆档案上的驳回原因展示缓存 + *

+ * 该列没有驳回原因时必须是 NULL(建表即 DEFAULT NULL)。但框架的 not_null 更新策略 + * 会跳过 patch 里的 null 字段,交给 updateById 的 null 到不了 SQL, + * 「已通过」的车辆会一直挂着上一次的驳回文案,故单独发一条只改该列的语句。 + * 它不碰生效快照指针。 + * + * @param vehicleId 车辆主键 + */ + private void clearVehicleRejectReason(Long vehicleId) { + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } + + /** + * 清空司机档案上的驳回原因展示缓存 + *

+ * 与车辆侧同理:null 交给 updateById 到不了 SQL,只能单独发一条只改该列的语句。 + * 它不碰生效快照指针,也不碰车辆绑定。 + * + * @param driverId 司机主键 + */ + private void clearDriverRejectReason(Long driverId) { + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); + } + + /** + * 目标对象的写入必须命中一行,否则整体失败 + *

+ * {@code updateById} 命中 0 行时**不抛异常、只返回 false**。原先丢弃这个返回值, + * 于是「审核记录已通过、司机/车辆档案一个字没变」能静默成立:生效快照指针没写进去, + * 对象下次接单仍被判未认证,而审核页显示的是已通过。此处让事务整体回滚, + * 宁可报错也不留下状态与档案互相矛盾的数据。 + * + * @param updated 更新结果 + * @param target 对象名称,用于提示 + * @param id 对象主键 + */ + private void requireTargetUpdated(boolean updated, String target, Long id) { + if (!updated) { + throw new ServiceException("未能更新" + target + "档案(ID:" + id + + "),本次审核已回滚、未生效;请核对该对象是否存在、未被删除,且与认证记录属于同一租户"); + } + } + + /** + * 把司机当前的车辆绑定原值搬进 patch,避免被 ALWAYS 更新策略清空 + *

+ * {@code driving_vehicle} 上标了 {@code FieldStrategy.ALWAYS},任何一次 + * {@code updateById} 都会把该列写成 patch 里的值(null 也照写)。 + * 而司机端的车辆列表是按这一列的车牌匹配出来的,一旦被清空, + * 页面就变成「暂无绑定车辆」,且无法从界面上恢复。 + * + * @param driverId 司机主键 + * @param patch 待更新的司机 patch(就地写入原值) + */ + private void copyDrivingVehicle(Long driverId, Driver patch) { + Driver current = driverService.getById(driverId); + if (current != null) { + patch.setDrivingVehicle(current.getDrivingVehicle()); + } + } + + /** + * 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象) + *

+ * 搬**司机自己申报的全部实名内容**,姓名与身份证号也在其中:审核通过即认可这次申报, + * 档案必须与生效值一致;否则司机端卡片显示档案里的名字、「重新认证」表单回填快照里的名字, + * 同一个页面上出现两个名字。 + *

+ * 仍不碰 {@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}: + * 内部管理归属与派工关系由管理端维护,不该被司机的提交内容覆盖。 + *

+ * 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), + * 因此不会把管理端填过、司机端没填的字段清空。 + *

+ * 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**: + * 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败; + * 何况历史快照本来就存在,阻断会让这些对象再也审不过去。 + * 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号, + * 必须阻断。 + * + * @param driver 待更新的司机 patch + * @param snapshotJson 该条记录的全量提交内容 + */ + private void applySnapshotToDriver(Driver driver, String snapshotJson) { + if (Func.isEmpty(snapshotJson)) { + return; + } + PersonCertSubmitRequest snapshot; + try { + snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class); + } catch (Exception exception) { + return; + } + if (snapshot == null) { + return; + } + // 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况), + // 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。 + // 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。 + if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) { + return; + } + // 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引, + // 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效 + requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId()); + driver.setDriverName(snapshot.getRealName()); + driver.setIdCardNo(snapshot.getIdCardNo()); + driver.setIdCardFront(snapshot.getIdCardFrontUrl()); + driver.setIdCardBack(snapshot.getIdCardBackUrl()); + driver.setIdCardValidStart(snapshot.getIdCardValidStart()); + driver.setIdCardValidEnd(snapshot.getIdCardValidEnd()); + driver.setIdCardLongTerm(snapshot.getIdCardLongTerm()); + driver.setHeadPhoto(snapshot.getHeadPhoto()); + driver.setDrivingType(snapshot.getDriverClass()); + driver.setDrivingLicenseNo(snapshot.getDriverFileNo()); + driver.setDrivingLicenseStartDate(snapshot.getDriverValidStart()); + driver.setDrivingLicenseEndDate(snapshot.getDriverValidEnd()); + driver.setDrivingLicenseLongTerm(snapshot.getDriverLongTerm()); + driver.setDrivingLicenseFront(snapshot.getDriverLicenseFrontUrl()); + driver.setDrivingLicenseBack(snapshot.getDriverLicenseBackUrl()); + driver.setQualificationType(snapshot.getQualificationType()); + driver.setQualificationNo(snapshot.getQualificationNo()); + driver.setQualificationFront(qualificationFrontOf(snapshot, snapshotJson)); + driver.setQualificationBack(snapshot.getQualificationBack()); + driver.setQualificationEndDate(snapshot.getQualificationValidEnd()); + driver.setQualificationLongTerm(snapshot.getQualificationLongTerm()); + } + + /** + * 身份证号不得落在另一名司机名下 + *

+ * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据, + * 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号 + * 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、 + * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。 + *

+ * 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断; + * 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。 + * + * @param idCardNo 快照里的身份证号 + * @param driverId 本次要写入的司机主键 + */ + private void requireIdCardNotTaken(String idCardNo, Long driverId) { + if (Func.isEmpty(idCardNo)) { + return; + } + Driver owner = driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getIdCardNo, idCardNo) + .last("LIMIT 1")); + if (owner != null && !Objects.equals(owner.getId(), driverId)) { + throw new ServiceException("身份证号 " + idCardNo + " 已属于另一名司机档案(ID:" + owner.getId() + + "),本次审核已回滚、未生效;请先处理档案归属"); + } + } + + /** + * 取从业资格证封面页地址,兼容改名前的旧键 + *

+ * 该字段原叫 {@code qualificationUrl},与司机档案列 {@code qualification_front} 不一致, + * 已统一为 {@code qualificationFront}。改名之前提交的记录里存的仍是旧键, + * 直接按新键取值会得到 null,审核通过后反而把已填的封面页丢掉,故这里回退读一次旧键。 + * + * @param snapshot 已解析的快照(可能因旧键而为 null) + * @param snapshotJson 原始快照 JSON,用于回退读取旧键 + * @return 封面页地址;新旧键都没有时为 null + */ + private String qualificationFrontOf(PersonCertSubmitRequest snapshot, String snapshotJson) { + if (Func.isNotEmpty(snapshot.getQualificationFront())) { + return snapshot.getQualificationFront(); + } + try { + return JSON.parseObject(snapshotJson).getString("qualificationUrl"); + } catch (Exception exception) { + // 旧键缺失或 JSON 结构异常都不该阻断写回,没有封面页就不写这一列 + return null; + } + } + + /** + * 把生效快照里的车辆证件字段搬进车辆档案(就地写入同一个 patch 对象) + *

+ * 只搬**证件内容**,不碰两类字段: + *

    + *
  • {@code plateNo}:车牌是这辆车的身份,不是本次提交可以改的内容;
  • + *
  • {@code organizationName} / {@code useDepartment}:内部管理归属, + * 由管理端维护,不该被司机的提交内容覆盖。
  • + *
+ * 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), + * 因此不会把管理端填过、司机端没填的字段清空。 + * + * @param vehicle 待更新的车辆 patch + * @param snapshotJson 该条记录的全量提交内容 + */ + private void applySnapshotToVehicle(TransportVehicle vehicle, String snapshotJson) { + if (Func.isEmpty(snapshotJson)) { + throw new ServiceException("该认证记录没有提交内容,无法写入车辆档案"); + } + VehicleCertSubmitRequest snapshot; + try { + snapshot = JSON.parseObject(snapshotJson, VehicleCertSubmitRequest.class); + } catch (Exception exception) { + throw new ServiceException("该认证记录的提交内容无法解析,未写入车辆档案"); + } + // 字段名对不上时 JSON 会解析成「全是 null」而不报错(早期格式的历史快照就是这种情况), + // 那样写回等于什么也没写。故用证件内容里的关键字段判定「这份快照是否是当前格式」。 + if (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg())) { + throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照)," + + "未写入车辆档案;请重新提交一次认证"); + } + vehicle.setOwnerName(snapshot.getOwnerName()); + vehicle.setPlateColor(snapshot.getPlateColor()); + vehicle.setVehicleType(snapshot.getVehicleType()); + vehicle.setVin(snapshot.getVin()); + vehicle.setOuterLength(snapshot.getOuterLength()); + vehicle.setOuterWidth(snapshot.getOuterWidth()); + vehicle.setOuterHeight(snapshot.getOuterHeight()); + vehicle.setApprovedLoadKg(snapshot.getApprovedLoadKg()); + vehicle.setTractionMassKg(snapshot.getTractionMassKg()); + vehicle.setEnergyType(snapshot.getEnergyType()); + vehicle.setBusinessRelation(snapshot.getBusinessRelation()); + vehicle.setCompulsoryScrapDate(snapshot.getCompulsoryScrapDate()); + vehicle.setCompulsoryScrapLongTerm(snapshot.getCompulsoryScrapLongTerm()); + vehicle.setDrivingLicenseNo(snapshot.getDrivingLicenseNo()); + vehicle.setDrivingLicenseImage(snapshot.getDrivingLicenseImage()); + vehicle.setDrivingLicenseMainBack(snapshot.getDrivingLicenseMainBack()); + vehicle.setDrivingLicenseViceFront(snapshot.getDrivingLicenseViceFront()); + vehicle.setDrivingLicenseViceBack(snapshot.getDrivingLicenseViceBack()); + vehicle.setDrivingLicenseStartDate(snapshot.getDrivingLicenseStartDate()); + vehicle.setDrivingLicenseEndDate(snapshot.getDrivingLicenseEndDate()); + vehicle.setDrivingLicenseLongTerm(snapshot.getDrivingLicenseLongTerm()); + vehicle.setRoadTransportCertNo(snapshot.getRoadTransportCertNo()); + vehicle.setRoadTransportCertImage(snapshot.getRoadTransportCertImage()); + vehicle.setRoadTransportCertStartDate(snapshot.getRoadTransportCertStartDate()); + vehicle.setRoadTransportCertEndDate(snapshot.getRoadTransportCertEndDate()); + vehicle.setRoadTransportCertLongTerm(snapshot.getRoadTransportCertLongTerm()); + vehicle.setRegistrationNo(snapshot.getRegistrationNo()); + vehicle.setRegistrationImage(snapshot.getRegistrationImage()); + } + + /** + * 取该对象当前的生效快照指针 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + * @return 指针值;对象不存在或从未认证通过时返回 null + */ + private Long effectiveRecordIdOf(String bizType, Long bizId) { + if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType)) { + TransportVehicle vehicle = transportVehicleService.getById(bizId); + return vehicle == null ? null : vehicle.getEffectiveCertRecordId(); + } + Driver driver = driverService.getById(bizId); + return driver == null ? null : driver.getEffectiveCertRecordId(); + } + + private TransportCertRecord requireRecord(Long recordId) { + if (Func.isEmpty(recordId)) { + throw new ServiceException("认证记录主键不能为空"); + } + TransportCertRecord record = certRecordService.getById(recordId); + if (record == null) { + throw new ServiceException("认证记录不存在"); + } + return record; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java index 50be61fb..4c6cd10f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java @@ -32,7 +32,9 @@ import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; +import org.springblade.system.pojo.entity.Dept; import org.springblade.transport.excel.ContractManageExcel; import org.springblade.transport.mapper.ContractManageMapper; import org.springblade.transport.pojo.entity.ContractManage; @@ -55,6 +57,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 合同管理 服务实现类 @@ -190,6 +194,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl latestList = list(Wrappers.lambdaQuery() + .select(ContractManage::getContractNo) + .eq(ContractManage::getIsDeleted, 0) + .eq(ContractManage::getOrganizationId, organizationId) + .likeRight(ContractManage::getContractNo, head) + .orderByDesc(ContractManage::getContractNo) + .last("LIMIT 50")); + Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$"); + int next = 1; + for (ContractManage item : latestList) { + String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo()); + if (Func.isEmpty(contractNo)) { + continue; + } + Matcher matcher = pattern.matcher(contractNo); + if (!matcher.matches()) { + continue; + } + next = Integer.parseInt(matcher.group(1)) + 1; + break; + } + if (next > 999) { + throw new ServiceException("当前部门本年合同流水号已达上限999"); + } + return head + String.format("%03d", next) + "号"; + } + private void refreshArchiveStatusOnApprove(ContractManage contractManage) { if (hasSealArchiveFile(contractManage.getContractFileJson())) { contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED); @@ -595,7 +644,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl> entry : originalApproved.entrySet()) { if (!requestKeys.contains(entry.getKey())) { - throw new ServiceException("已审核通过的合同文件不允许删除"); + throw new ServiceException("已审核通过的用印材料不允许删除"); } } return merged.isEmpty() ? null : JsonUtil.toJson(merged); @@ -670,8 +719,9 @@ public class ContractManageServiceImpl extends BaseServiceImpllambdaQuery() - .eq(ContractManage::getIsDeleted, 0) - .eq(ContractManage::getContractName, contractManage.getContractName()) - .ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId())); - if (count > 0) { - throw new ServiceException("合同名称已存在"); - } - } - private void validateContractPartyCombinationUnique(ContractManage contractManage) { if (Func.isEmpty(contractManage.getContractCategory()) || Func.isEmpty(contractManage.getPartyA()) @@ -822,9 +862,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl info = asMap(processInfo); - if (!"20".equals(firstText(info, "fdProcessStatus"))) { - return false; - } - Object historyHandlerInfos = asMap(info.get("fdTaskInfo")).get("historyHandlerInfos"); - if (!(historyHandlerInfos instanceof List historyList) || historyList.isEmpty()) { - return false; - } - Object lastHandler = historyList.get(historyList.size() - 1); - return "N6".equals(firstText(asMap(lastHandler), "nodeNumber")); - } - private void applyProcessResult(String customerIdText, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index a62aca07..f3545757 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -34,6 +34,7 @@ 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; +import org.springblade.transport.mk.MkProcessNodeHelper; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; @@ -50,6 +51,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.service.IBfiService; import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; import org.springblade.transport.excel.CustomerArchiveExcel; import org.springblade.transport.config.BfiProperties; @@ -163,6 +165,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl taskInfo = asStringObjectMap(info.get("fdTaskInfo")); - String currentNode = joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、"); + String currentProcessor = MkProcessNodeHelper.currentProcessor(processInfo); + String currentNode = MkProcessNodeHelper.currentNode(processInfo); if (Func.isEmpty(currentNode) && Func.isEmpty(currentProcessor)) { log.warn("同步客商当前节点跳过,未解析到节点名称或处理人 id={}", id); return false; @@ -919,15 +922,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl deptIdList = Func.toLongList(customer.getDeptIds()); - if (Func.isEmpty(deptIdList)) { - throw new ServiceException("所属组织不正确"); - } - customer.setDeptId(deptIdList.get(0)); - } - customer.setDeptName(trimToEmpty(customer.getDeptName())); + applyFixedOwnerDept(customer); customer.setAccessType(Func.isEmpty(customer.getAccessType()) ? ACCESS_TEMPORARY : customer.getAccessType()); customer.setApprovalStatus(Func.isEmpty(customer.getApprovalStatus()) ? APPROVAL_DRAFT : customer.getApprovalStatus()); customer.setKingdeeStatus(Func.isEmpty(customer.getKingdeeStatus()) ? KINGDEE_STATUS_NONE : customer.getKingdeeStatus()); @@ -2166,6 +2161,25 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl deptIdList = Func.toLongList(deptIds); + if (Func.isEmpty(deptIdList) || deptIdList.get(0) == null) { + throw new ServiceException("所属组织「" + FIXED_OWNER_DEPT_NAME + "」不正确"); + } + Long deptId = deptIdList.get(0); + customer.setDeptId(deptId); + customer.setDeptIds(String.valueOf(deptId)); + } + private String trimToEmpty(String value) { return value == null ? "" : value.trim(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java index 551479d3..148ce41e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java @@ -22,14 +22,15 @@ */ package org.springblade.transport.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; -import org.springblade.system.feign.IUserClient; -import org.springblade.system.pojo.entity.User; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; @@ -37,16 +38,14 @@ import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverAppService; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springblade.transport.wrapper.DriverWrapper; import org.springframework.stereotype.Service; -import java.util.ArrayList; +import java.time.LocalDate; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; /** @@ -58,7 +57,7 @@ public class DriverAppServiceImpl implements IDriverAppService { private final IDriverService driverService; private final ITransportVehicleService transportVehicleService; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; @Override public DriverVO currentByPhone(String mobile) { @@ -72,22 +71,15 @@ public class DriverAppServiceImpl implements IDriverAppService { if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { return List.of(); } - List plates = splitPlates(driver.getDrivingVehicle()); + List plates = VehiclePlateSupport.splitPlates(driver.getDrivingVehicle()); if (plates.isEmpty()) { return List.of(); } - // 精确匹配 + 规范化匹配(兼容库中带间隔符/横线的车牌) - List vehicles = transportVehicleService.list(Wrappers.lambdaQuery() - .and(w -> { - w.in(TransportVehicle::getPlateNo, plates); - for (String plate : plates) { - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - } - }) - .orderByDesc(TransportVehicle::getUpdateTime)); + // 精确 + 规范化双条件匹配,统一走共享实现(避免各调用点各自实现而漏掉写法差异) + LambdaQueryWrapper plateQuery = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(plateQuery, plates); + List vehicles = transportVehicleService.list( + plateQuery.orderByDesc(TransportVehicle::getUpdateTime)); // 去重(精确与规范化可能命中同一条) Map uniq = new LinkedHashMap<>(); for (TransportVehicle vehicle : vehicles) { @@ -98,6 +90,11 @@ public class DriverAppServiceImpl implements IDriverAppService { return uniq.values().stream().map(this::toCard).collect(Collectors.toList()); } + @Override + public Driver currentDriverEntity() { + return currentDriver(null); + } + private Driver currentDriver(String mobileHint) { Long userId = AuthUtil.getUserId(); if (userId == null || userId <= 0) { @@ -119,57 +116,66 @@ public class DriverAppServiceImpl implements IDriverAppService { return driver; } - private List splitPlates(String drivingVehicle) { - String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",") - .replace(";", ",").replace("/", ",").replace("|", ","); - Set plates = new LinkedHashSet<>(); - for (String part : Func.toStrList(",", normalized)) { - if (Func.isEmpty(part)) { - continue; - } - String plate = normalizePlate(part); - if (Func.isNotEmpty(plate)) { - plates.add(plate); - } - } - return new ArrayList<>(plates); - } - - /** 车牌规范化:去空格/横线/间隔符并转大写,便于与车辆表关联 */ - private String normalizePlate(String plateNo) { - if (Func.isEmpty(plateNo)) { - return ""; - } - return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT); - } - private DriverVehicleCardVO toCard(TransportVehicle vehicle) { DriverVehicleCardVO card = new DriverVehicleCardVO(); card.setId(vehicle.getId()); card.setPlateNo(vehicle.getPlateNo()); + card.setOwnerName(Func.toStr(vehicle.getOwnerName(), "")); + card.setPlateColor(Func.toStr(vehicle.getPlateColor(), "")); card.setVehicleType(vehicle.getVehicleType()); - card.setLicenseFrontUrl(Func.toStr(vehicle.getDrivingLicenseImage(), "")); - card.setLicenseBackUrl(firstNotEmpty(vehicle.getDrivingLicenseViceFront(), vehicle.getDrivingLicenseMainBack())); - card.setRoadTransportNo(Func.toStr(vehicle.getRoadTransportCertNo(), "")); - card.setRoadTransportUrl(Func.toStr(vehicle.getRoadTransportCertImage(), "")); - card.setVin(""); - card.setEngineNo(""); - if (vehicle.getDrivingLicenseEndDate() != null) { - card.setLicenseValidEnd(vehicle.getDrivingLicenseEndDate().toString()); - } else if (Integer.valueOf(1).equals(vehicle.getDrivingLicenseLongTerm())) { - card.setLicenseValidEnd("长期"); - } else { - card.setLicenseValidEnd(""); - } + card.setVin(Func.toStr(vehicle.getVin(), "")); + card.setOuterLength(vehicle.getOuterLength()); + card.setOuterWidth(vehicle.getOuterWidth()); + card.setOuterHeight(vehicle.getOuterHeight()); + card.setApprovedLoadKg(vehicle.getApprovedLoadKg()); + card.setTractionMassKg(vehicle.getTractionMassKg()); + card.setEnergyType(Func.toStr(vehicle.getEnergyType(), "")); + card.setBusinessRelation(Func.toStr(vehicle.getBusinessRelation(), "")); + card.setCompulsoryScrapDate(dateText(vehicle.getCompulsoryScrapDate(), vehicle.getCompulsoryScrapLongTerm())); + card.setCompulsoryScrapLongTerm(vehicle.getCompulsoryScrapLongTerm()); + card.setDrivingLicenseNo(Func.toStr(vehicle.getDrivingLicenseNo(), "")); + // 行驶证四张图各自独立,不再把「主页反面」与「副页正面」挤在同一个字段里: + // 它们本来就是证件上不同的四面,混在一起会让「重新认证」回填出错的图。 + card.setDrivingLicenseImage(Func.toStr(vehicle.getDrivingLicenseImage(), "")); + card.setDrivingLicenseMainBack(Func.toStr(vehicle.getDrivingLicenseMainBack(), "")); + card.setDrivingLicenseViceFront(Func.toStr(vehicle.getDrivingLicenseViceFront(), "")); + card.setDrivingLicenseViceBack(Func.toStr(vehicle.getDrivingLicenseViceBack(), "")); + card.setDrivingLicenseStartDate(dateText(vehicle.getDrivingLicenseStartDate(), null)); + card.setDrivingLicenseEndDate(dateText(vehicle.getDrivingLicenseEndDate(), vehicle.getDrivingLicenseLongTerm())); + card.setDrivingLicenseLongTerm(vehicle.getDrivingLicenseLongTerm()); + card.setRoadTransportCertNo(Func.toStr(vehicle.getRoadTransportCertNo(), "")); + card.setRoadTransportCertImage(Func.toStr(vehicle.getRoadTransportCertImage(), "")); + card.setRoadTransportCertStartDate(dateText(vehicle.getRoadTransportCertStartDate(), null)); + card.setRoadTransportCertEndDate(dateText(vehicle.getRoadTransportCertEndDate(), vehicle.getRoadTransportCertLongTerm())); + card.setRoadTransportCertLongTerm(vehicle.getRoadTransportCertLongTerm()); + card.setRegistrationNo(Func.toStr(vehicle.getRegistrationNo(), "")); + card.setRegistrationImage(Func.toStr(vehicle.getRegistrationImage(), "")); card.setCertificationStatus(vehicle.getCertificationStatus()); return card; } - private String firstNotEmpty(String first, String second) { - if (Func.isNotEmpty(first)) { - return first; + /** + * 日期字段的下发值:只下发 {@code yyyy-MM-dd} 或空串,**从不下发「长期」这类文字** + *

+ * 卡片是「重新认证」表单的回填来源,值会直接进日期框,所以两条约束必须守住: + *

    + *
  • 长期有效时下发**空串**:长期与否由同名字段 {@code xxxLongTerm} 表达, + * 界面上显示「长期」是前端的事。把「长期」当日期下发,用户关掉长期有效开关后 + * 日期框里会留着一个字面量「长期」,提交时后端按 {@code LocalDate} 反序列化直接报错。
  • + *
  • 长期有效时**不下发止期**:止期在「长期」语义下已作废(管理端勾长期有效时也会清掉它), + * 下发旧值会被表单原样回传、审核通过后又写回车辆行,形成「长期有效 + 一个过期止期」 + * 的脏数据——车辆行上一次长期有效的提交正是这样留下 2040-08-14 的。
  • + *
+ * + * @param date 日期 + * @param longTerm 长期有效标志:1 表示长期 + * @return {@code yyyy-MM-dd} 或空串 + */ + private String dateText(LocalDate date, Integer longTerm) { + if (Integer.valueOf(1).equals(longTerm)) { + return ""; } - return Func.toStr(second, ""); + return date == null ? "" : date.toString(); } /** @@ -180,7 +186,13 @@ public class DriverAppServiceImpl implements IDriverAppService { String selfPhone = resolveSelfPhone(userId); String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim(); if (Func.isNotEmpty(hint)) { - if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) { + if (Func.isEmpty(selfPhone)) { + // 解析不出本人号码时不能拿前端传的 mobile 去查: + // currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。 + // 这里返回空让它退回按 user_id 查,宁可查不到也不越权。 + return null; + } + if (!selfPhone.equals(hint)) { throw new ServiceException("只能查询本人司机档案"); } return hint; @@ -190,21 +202,29 @@ public class DriverAppServiceImpl implements IDriverAppService { private String resolveSelfPhone(Long userId) { String account = AuthUtil.getUserAccount(); - if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + if (isMobileLike(account)) { return account.trim(); } - R result = userClient.userInfoById(userId); - if (result == null || !R.isSuccess(result) || result.getData() == null) { - return Func.isEmpty(account) ? null : account.trim(); - } - User user = result.getData(); - if (Func.isNotEmpty(user.getPhone())) { - return user.getPhone().trim(); - } - if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) { - return user.getAccount().trim(); + // 小程序用户 phone 带 @Sensitive(MOBILE),经 Feign 回来是脱敏串, + // 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。 + R result = mpUserClient.userById(userId); + if (result != null && R.isSuccess(result) && result.getData() != null) { + String userAccount = result.getData().getAccount(); + if (isMobileLike(userAccount)) { + return userAccount.trim(); + } } return Func.isEmpty(account) ? null : account.trim(); } + /** + * 是否是「可作为司机档案手机号使用」的号码串。 + *

+ * 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号 + * (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。 + */ + private boolean isMobileLike(String value) { + return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java new file mode 100644 index 00000000..ca7ef54e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -0,0 +1,574 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.constant.BladeConstant; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.IDriverAppService; +import org.springblade.transport.service.IDriverCertService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.CertificationRuleSupport; +import org.springblade.transport.support.VehiclePlateSupport; +import org.springblade.transport.wrapper.TransportCertRecordWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 司机端认证服务实现 + *

+ * 提交链路只做三件事:把本次提交的内容**原样**存成一条新的待审记录、 + * 把该对象前一条待审记录置为已作废、刷新展示缓存。 + *

+ * ⚠️ 提交**绝不**修改 {@code effective_cert_record_id},也**不覆盖**证件字段, + * 这正是「提交审核不影响车辆正常使用」的实现方式:待审内容只存在于记录表里, + * 在审核通过之前,该对象对外呈现的仍然是旧的生效值。 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class DriverCertServiceImpl implements IDriverCertService { + + /** + * 证件文本字段的长度上限:与车辆表列宽及管理端校验保持一致 + */ + private static final int SHORT_TEXT_MAX_LENGTH = 50; + + /** + * 所有人(行驶证登记的单位名称)长度上限 + */ + private static final int OWNER_NAME_MAX_LENGTH = 100; + + private final IDriverAppService driverAppService; + private final IDriverService driverService; + private final ITransportVehicleService transportVehicleService; + private final ITransportCertRecordService transportCertRecordService; + + /** + * 司机端提交的车辆认证内容校验 + *

+ * 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过。 + * 校验发生在**落快照之前**——待审内容虽然要等审核通过才写进车辆档案,但它已经是一条 + * 不可变记录、并会在通过时按字段写回车辆行({@code applySnapshotToVehicle}), + * 校验只做在「新建车辆」那一条路上,重新认证就成了绕过校验的后门。 + * + * @param request 提交内容 + */ + private void validateCertContent(VehicleCertSubmitRequest request) { + requireText(request.getPlateColor(), "车牌颜色不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getVehicleType(), "车辆类型不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getOwnerName(), "所有人不能为空", OWNER_NAME_MAX_LENGTH); + requireText(request.getEnergyType(), "能源类型不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getBusinessRelation(), "业务关系不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getDrivingLicenseNo(), "行驶证档案编号不能为空", SHORT_TEXT_MAX_LENGTH); + requireText(request.getRoadTransportCertNo(), "道路运输证号不能为空", SHORT_TEXT_MAX_LENGTH); + Optional.ofNullable(request.getVin()) + .filter(Func::isNotEmpty) + .ifPresent(vin -> requireText(vin, "车架号格式不正确", 17)); + requireNonNegative(request.getApprovedLoadKg(), "核定载质量不能为空", "核定载质量不能小于0"); + requireNonNegative(request.getTractionMassKg(), "准牵引总质量不能为空", "准牵引总质量不能小于0"); + requireNonNegative(request.getOuterLength(), "外廓长度不能为空", "外廓长度不能小于0"); + requireNonNegative(request.getOuterWidth(), "外廓宽度不能为空", "外廓宽度不能小于0"); + requireNonNegative(request.getOuterHeight(), "外廓高度不能为空", "外廓高度不能小于0"); + requireDateRange(request.getDrivingLicenseStartDate(), request.getDrivingLicenseEndDate(), + request.getDrivingLicenseLongTerm(), "请选择行驶证有效期", "行驶证有效期止不能早于有效期起"); + requireDateRange(request.getRoadTransportCertStartDate(), request.getRoadTransportCertEndDate(), + request.getRoadTransportCertLongTerm(), "请选择道路运输证有效期", "道路运输证有效期止不能早于有效期起"); + if (Func.isEmpty(request.getCompulsoryScrapDate()) + && !Integer.valueOf(1).equals(request.getCompulsoryScrapLongTerm())) { + throw new ServiceException("请选择强制报废日期"); + } + } + + /** + * 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT + * 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。 + *

+ * 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属), + * 拿它拦住重新认证只会让司机无从下手。 + * + * @param driver 当前司机 + */ + private void requireOrganizationForNewVehicle(Driver driver) { + if (Func.isEmpty(driver.getOrganizationName())) { + throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交"); + } + } + + /** + * 文本必填 + 长度校验 + * + * @param value 待校验值 + * @param emptyText 为空时的提示 + * @param maxLength 长度上限 + */ + private void requireText(String value, String emptyText, int maxLength) { + if (Func.isEmpty(value)) { + throw new ServiceException(emptyText); + } + if (value.trim().length() > maxLength) { + throw new ServiceException(emptyText.replace("不能为空", "") + "不能超过" + maxLength + "字"); + } + } + + /** + * 数值必填 + 非负校验 + * + * @param value 待校验值 + * @param emptyText 为空时的提示 + * @param negativeText 为负数时的提示 + */ + private void requireNonNegative(Integer value, String emptyText, String negativeText) { + if (value == null) { + throw new ServiceException(emptyText); + } + if (value < 0) { + throw new ServiceException(negativeText); + } + } + + /** + * 有效期校验:未勾「长期有效」时**止期**必填;勾了长期有效则止期也不要求 + *

+ * 起期不再是必填:司机端表单不采集它(管理端车辆表单同样只填止期), + * 存量车辆档案里起期列全是空的,强制必填会让每一次重新认证都卡在提交前。 + * 起止先后只在**两端都有值**时才比较——只有止期时没有可比的对象。 + * + * @param start 有效期起,可为空 + * @param end 有效期止 + * @param longTerm 长期有效标志:1 表示长期 + * @param emptyText 缺止期时的提示 + * @param rangeText 区间颠倒时的提示 + */ + private void requireDateRange(LocalDate start, LocalDate end, Integer longTerm, + String emptyText, String rangeText) { + if (Integer.valueOf(1).equals(longTerm)) { + return; + } + if (end == null) { + throw new ServiceException(emptyText); + } + if (start != null && end.isBefore(start)) { + throw new ServiceException(rangeText); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long submitPersonCert(PersonCertSubmitRequest request) { + if (Func.isEmpty(request)) { + throw new ServiceException("认证内容不能为空"); + } + Driver driver = requireCurrentDriver(); + String snapshotJson = JSON.toJSONString(request); + + // 先作废本人上一条待审,再落新记录:任何时刻同一对象至多一条待审。 + // 作废只动「待审」,已通过/已驳回都是终态,尤其不得碰生效快照指着的那条。 + supersedePending(TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId()); + + TransportCertRecord record = newRecord( + TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId(), + Func.toStr(driver.getDriverName(), ""), snapshotJson, driver.getUserId()); + transportCertRecordService.save(record); + + // 展示缓存:让小程序卡片显示「审核中」。可用性判定不看这个字段。 + updateDriverDisplayCache(driver.getId(), TransportCertRecord.STATUS_PENDING, null); + return record.getId(); + } + + @Override + public PersonCertSubmitRequest getEffectivePersonCert() { + Driver driver = requireCurrentDriver(); + // 当前司机实体已加载过,直接读它身上的指针,不再回查一次 + TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId()); + return effective == null + ? null + : JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long submitVehicleCert(VehicleCertSubmitRequest request) { + if (Func.isEmpty(request)) { + throw new ServiceException("认证内容不能为空"); + } + Driver driver = requireCurrentDriver(); + // 校验在 resolveBoundVehicle 之前:它既覆盖「新建车辆」,也覆盖「重新认证」—— + // 后者原先一点校验都不做,内容全靠审核环节兜底 + validateCertContent(request); + TransportVehicle vehicle = resolveBoundVehicle(driver, request); + String snapshotJson = JSON.toJSONString(request); + + supersedePending(TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId()); + + TransportCertRecord record = newRecord( + TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId(), + Func.toStr(vehicle.getPlateNo(), ""), snapshotJson, driver.getUserId()); + transportCertRecordService.save(record); + + updateVehicleDisplayCache(vehicle.getId(), TransportCertRecord.STATUS_PENDING, null); + return record.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean deleteVehicleCert(Long vehicleId) { + if (Func.isEmpty(vehicleId)) { + throw new ServiceException("车辆主键不能为空"); + } + Driver driver = requireCurrentDriver(); + TransportVehicle vehicle = transportVehicleService.getById(vehicleId); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + // 越权校验:与提交链路同一套绑定判定 + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权删除该车辆的认证"); + } + // 逻辑删除车辆;认证记录**不删**——它是不可变快照,且是「曾通过即永久可用」的依据 + return transportVehicleService.deleteLogic(Collections.singletonList(vehicleId)); + } + + @Override + public VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId) { + if (Func.isEmpty(vehicleId)) { + throw new ServiceException("车辆主键不能为空"); + } + TransportVehicle vehicle = transportVehicleService.getById(vehicleId); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + if (!VehiclePlateSupport.isBound(requireCurrentDriver().getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权查看该车辆的认证信息"); + } + TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId()); + return effective == null + ? null + : JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class); + } + + @Override + public List listRecords(String bizType, Long bizId) { + requireValidBizType(bizType); + requireReadableTarget(bizType, bizId); + return transportCertRecordService.selectRecordList(bizType, bizId); + } + + @Override + public TransportCertRecordVO getRecord(Long recordId) { + TransportCertRecord record = transportCertRecordService.getById(recordId); + if (record == null) { + throw new ServiceException("认证记录不存在"); + } + requireReadableTarget(record.getBizType(), record.getBizId()); + return TransportCertRecordWrapper.build().entityVO(record); + } + + /** + * 把该认证对象当前那条待审记录置为已作废 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + */ + private void supersedePending(String bizType, Long bizId) { + TransportCertRecord pending = transportCertRecordService.selectPendingRecord(bizType, bizId); + TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(pending); + if (superseded != null) { + transportCertRecordService.updateById(superseded); + } + } + + /** + * 构造一条待审记录 + *

+ * 状态必须显式设置为待审:框架在插入时会把空的 {@code status} 静默填成 1, + * 而 1 在本表是「已通过」,那会让一次提交凭空变成审核通过。 + * + * @param submitUser 提交人**用户ID**(blade_mp_user.id)——审核页要靠它翻译成姓名。 + * 不要传司机档案ID:两者都是雪花ID、类型也相同,传错不会报任何错, + * 只会让「提交人」一栏显示成一串数字。 + */ + private TransportCertRecord newRecord(String bizType, Long bizId, String bizKey, + String snapshotJson, Long submitUser) { + TransportCertRecord record = new TransportCertRecord(); + record.setBizType(bizType); + record.setBizId(bizId); + record.setBizKey(bizKey); + record.setSnapshotJson(snapshotJson); + record.setSubmitTime(LocalDateTime.now()); + record.setSubmitUser(submitUser); + CertificationRuleSupport.prepareForSubmit(record); + return record; + } + + /** + * 按生效快照指针取那条记录 + *

+ * 只按指针查,不看记录表里任何一条的「最新状态」——这正是生效值与待审值分离的意义。 + * + * @param effectiveRecordId 生效快照指针 + * @return 生效快照记录;指针为空时返回 null + */ + private TransportCertRecord findEffectiveRecord(Long effectiveRecordId) { + return Func.isEmpty(effectiveRecordId) + ? null + : transportCertRecordService.getById(effectiveRecordId); + } + + /** + * 刷新司机侧的「最新提交状态」展示缓存 + *

+ * 只写展示缓存,**不碰生效快照指针**。 + */ + private void updateDriverDisplayCache(Long driverId, Integer status, String rejectReason) { + Driver patch = new Driver(); + patch.setId(driverId); + patch.setCertificationStatus(status); + patch.setCertificationRejectReason(rejectReason); + // 必须带上 driving_vehicle 原值:该列标了 FieldStrategy.ALWAYS, + // 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL, + // 而司机端的车辆列表正是按这一列的车牌匹配出来的(清空即「暂无绑定车辆」)。 + Driver current = driverService.getById(driverId); + if (current != null) { + patch.setDrivingVehicle(current.getDrivingVehicle()); + } + driverService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 重新提交时驳回原因应为空,但框架的 not_null 更新策略会跳过 patch 里的 null, + // 只写 null 到不了 SQL,旧驳回文案会一直挂到审核通过,故单独清一次 + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); + } + } + + /** + * 刷新车辆侧的「最新提交状态」展示缓存 + *

+ * 只写展示缓存,**不碰生效快照指针**,也不覆盖车辆当前的证件字段。 + */ + private void updateVehicleDisplayCache(Long vehicleId, Integer status, String rejectReason) { + TransportVehicle patch = new TransportVehicle(); + patch.setId(vehicleId); + patch.setCertificationStatus(status); + patch.setCertificationRejectReason(rejectReason); + transportVehicleService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 与司机侧同理:null 交给 updateById 到不了 SQL,旧驳回文案会一直挂着 + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } + } + + /** + * 解析本次提交指向的车辆,并校验它确实绑定在当前司机名下 + * + * @param driver 当前司机 + * @param request 提交内容 + * @return 目标车辆 + */ + private TransportVehicle resolveBoundVehicle(Driver driver, VehicleCertSubmitRequest request) { + // 按 id 定位:只能操作本人绑定的车,否则越权 + if (Func.isNotEmpty(request.getId())) { + TransportVehicle vehicle = transportVehicleService.getById(request.getId()); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权操作该车辆的认证"); + } + return vehicle; + } + + String normalized = VehiclePlateSupport.normalize(request.getPlateNo()); + if (Func.isEmpty(normalized)) { + throw new ServiceException("车牌号不能为空"); + } + + // 按车牌定位:已登记的车必须已绑定到本人;未登记的车视为「新增」,就地建档并绑定。 + // 小程序「+ 新增车辆」允许司机直接填车牌,若这里只认已有绑定, + // 新车的提交必然失败——而司机名下还没有这辆车,也就永远无从绑定。 + TransportVehicle existing = findVehicleByAnyPlateForm(normalized); + if (existing != null) { + if (existing.getIsDeleted() != null && existing.getIsDeleted() != 0) { + throw new ServiceException("该车牌已被删除,请联系管理员"); + } + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), existing.getPlateNo())) { + // 已登记但不在本人名下,可能属于其他司机 —— 拒绝,不能顺手改归属 + throw new ServiceException("该车牌已归属其他司机,请联系管理员"); + } + return existing; + } + return createVehicleForDriver(driver, request, normalized); + } + + /** + * 按车牌(容忍写法差异)查已登记的车辆 + * + * @param normalizedPlate 已规范化的车牌 + * @return 车辆;未登记返回 null + */ + private TransportVehicle findVehicleByAnyPlateForm(String normalizedPlate) { + LambdaQueryWrapper plateQuery = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(plateQuery, List.of(normalizedPlate)); + return transportVehicleService.list(plateQuery).stream() + .filter(item -> normalizedPlate.equals(VehiclePlateSupport.normalize(item.getPlateNo()))) + .findFirst() + .orElse(null); + } + + /** + * 为司机新增一辆车并绑定到其名下 + *

+ * 只写入车辆的基本识别信息与本次提交的证件内容;车辆是否「已认证」仍由 + * 生效快照指针决定,故此处建档**不会**让一辆未审核通过的车变得可用。 + * + * @param driver 当前司机 + * @param request 提交内容 + * @param normalizedPlate 已规范化的车牌 + * @return 新建的车辆 + */ + private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request, + String normalizedPlate) { + // 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件 + requireOrganizationForNewVehicle(driver); + + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setPlateNo(normalizedPlate); + // 所属组织取司机档案:车辆表的该列 NOT NULL 且无默认值,不填这条 INSERT 直接报 + // 「Field 'organization_name' doesn't have a default value」(严格模式下必错)。 + // 车辆归到司机所属组织名下,管理端按组织查看时才看得到这台车。 + vehicle.setOrganizationName(driver.getOrganizationName()); + vehicle.setOwnerName(request.getOwnerName()); + vehicle.setPlateColor(request.getPlateColor()); + vehicle.setVehicleType(request.getVehicleType()); + vehicle.setVin(request.getVin()); + vehicle.setOuterLength(request.getOuterLength()); + vehicle.setOuterWidth(request.getOuterWidth()); + vehicle.setOuterHeight(request.getOuterHeight()); + vehicle.setApprovedLoadKg(request.getApprovedLoadKg()); + vehicle.setTractionMassKg(request.getTractionMassKg()); + vehicle.setEnergyType(request.getEnergyType()); + vehicle.setBusinessRelation(request.getBusinessRelation()); + vehicle.setCompulsoryScrapDate(request.getCompulsoryScrapDate()); + vehicle.setCompulsoryScrapLongTerm(request.getCompulsoryScrapLongTerm()); + // 行驶证四张图与车辆表四列一一对应,不再把「主页反面」塞进语义含糊的「副页」字段 + vehicle.setDrivingLicenseNo(request.getDrivingLicenseNo()); + vehicle.setDrivingLicenseImage(request.getDrivingLicenseImage()); + vehicle.setDrivingLicenseMainBack(request.getDrivingLicenseMainBack()); + vehicle.setDrivingLicenseViceFront(request.getDrivingLicenseViceFront()); + vehicle.setDrivingLicenseViceBack(request.getDrivingLicenseViceBack()); + vehicle.setDrivingLicenseStartDate(request.getDrivingLicenseStartDate()); + vehicle.setDrivingLicenseEndDate(request.getDrivingLicenseEndDate()); + vehicle.setDrivingLicenseLongTerm(request.getDrivingLicenseLongTerm()); + vehicle.setRoadTransportCertNo(request.getRoadTransportCertNo()); + vehicle.setRoadTransportCertStartDate(request.getRoadTransportCertStartDate()); + vehicle.setRoadTransportCertEndDate(request.getRoadTransportCertEndDate()); + vehicle.setRoadTransportCertLongTerm(request.getRoadTransportCertLongTerm()); + vehicle.setRoadTransportCertImage(request.getRoadTransportCertImage()); + // 机动车登记编号该列同样 NOT NULL:司机端是选填,缺值写空串而不是 null + vehicle.setRegistrationNo(Func.toStr(request.getRegistrationNo(), "")); + vehicle.setRegistrationImage(request.getRegistrationImage()); + vehicle.setStatus(BladeConstant.DB_STATUS_NORMAL); + transportVehicleService.save(vehicle); + + // 绑定到司机名下,否则本人后续仍无法操作这辆车 + String drivingVehicle = Func.isEmpty(driver.getDrivingVehicle()) + ? normalizedPlate + : driver.getDrivingVehicle() + "," + normalizedPlate; + // 「最多 5 辆 + 车牌格式」在写入前校验:字段只有 varchar(100), + // 超限内容一旦写进去要么报错、要么被静默截断成半个车牌(还会被当成一辆车) + VehiclePlateSupport.validateBoundVehicles(drivingVehicle); + Driver patch = new Driver(); + patch.setId(driver.getId()); + patch.setDrivingVehicle(drivingVehicle); + driverService.updateById(patch); + driver.setDrivingVehicle(drivingVehicle); + return vehicle; + } + + /** + * 校验当前登录用户能否查看该认证对象的记录 + *

+ * 司机只能查自己的;车辆必须是本人绑定的。越权一律拒绝,不返回空列表 + * ——空列表会让调用方以为「没有记录」,而不是「无权查看」。 + */ + private void requireReadableTarget(String bizType, Long bizId) { + if (Func.isEmpty(bizId)) { + throw new ServiceException("认证对象主键不能为空"); + } + Driver driver = requireCurrentDriver(); + if (TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)) { + if (!bizId.equals(driver.getId())) { + throw new ServiceException("无权查看其他司机的认证记录"); + } + return; + } + TransportVehicle vehicle = transportVehicleService.getById(bizId); + if (vehicle == null) { + throw new ServiceException("车辆不存在"); + } + if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) { + throw new ServiceException("无权查看该车辆的认证记录"); + } + } + + private void requireValidBizType(String bizType) { + if (!CertificationRuleSupport.isValidBizType(bizType)) { + throw new ServiceException("认证对象类型不正确"); + } + } + + private Driver requireCurrentDriver() { + Driver driver = driverAppService.currentDriverEntity(); + if (driver == null) { + throw new ServiceException("未找到当前登录用户对应的司机档案"); + } + return driver; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 204a7d61..97e7186b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -36,20 +36,19 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.system.cache.UserCache; import org.springblade.core.tool.utils.Func; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.ISysClient; -import org.springblade.system.feign.IUserClient; -import org.springblade.system.pojo.entity.User; -import org.springblade.system.pojo.entity.UserInfo; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springblade.system.pojo.enums.UserSex; -import org.springblade.system.pojo.enums.UserType; import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.mapper.DriverMapper; -import org.springblade.transport.mapper.TransportVehicleMapper; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -69,15 +68,14 @@ public class DriverServiceImpl extends BaseServiceImpl imp private static final int NAME_MAX_LENGTH = 10; private static final int ID_CARD_MAX_LENGTH = 18; private static final int MOBILE_MAX_LENGTH = 20; - private static final int VEHICLE_NO_MAX_LENGTH = 30; + private static final int VEHICLE_NO_MAX_LENGTH = 100; private static final int SHORT_TEXT_MAX_LENGTH = 50; private static final int ADDRESS_MAX_LENGTH = 200; private static final int REMARK_MAX_LENGTH = 200; private static final int DEFAULT_ENABLED_STATUS = 1; private static final int DEFAULT_FALSE = 0; - private final TransportVehicleMapper transportVehicleMapper; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; private final ISysClient sysClient; @Override @@ -255,15 +253,11 @@ public class DriverServiceImpl extends BaseServiceImpl imp validateLength(driver.getIdCardNo(), ID_CARD_MAX_LENGTH, "身份证号不能超过18字"); validateLength(driver.getMobile(), MOBILE_MAX_LENGTH, "手机号不能超过20字"); validateLength(driver.getEmergencyContactMobile(), MOBILE_MAX_LENGTH, "紧急联系人手机号不能超过20字"); - validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过30字"); - if (Func.isNotEmpty(driver.getDrivingVehicle())) { - Long vehicleCount = transportVehicleMapper.selectCount(Wrappers.lambdaQuery() - .eq(TransportVehicle::getIsDeleted, 0) - .eq(TransportVehicle::getPlateNo, driver.getDrivingVehicle())); - if (vehicleCount == null || vehicleCount <= 0) { - throw new ServiceException("驾驶车辆不存在,请重新选择"); - } - } + validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过100字"); + // 「驾驶车辆」是**逗号并列的多辆车**(小程序每绑一辆就追加一个车牌), + // 不是一个车牌。此前这里拿整串去车牌表精确查,绑了两辆车的司机 + // 在司机管理页一保存就会撞上「驾驶车辆不存在」——校验口径必须与写入方一致。 + VehiclePlateSupport.validateBoundVehicles(driver.getDrivingVehicle()); validateLength(driver.getDrivingLicenseNo(), SHORT_TEXT_MAX_LENGTH, "驾驶证档案编号不能超过50字"); validateLength(driver.getQualificationNo(), SHORT_TEXT_MAX_LENGTH, "资格证号不能超过50字"); validateLength(driver.getOrganizationName(), SHORT_TEXT_MAX_LENGTH, "所属组织不能超过50字"); @@ -315,13 +309,13 @@ public class DriverServiceImpl extends BaseServiceImpl imp tenantId = driver.getTenantId(); } if (Func.isEmpty(tenantId)) { - throw new ServiceException("租户信息为空,无法同步系统用户"); + throw new ServiceException("租户信息为空,无法同步小程序用户"); } String mobile = driver.getMobile(); if (Func.isEmpty(mobile) || mobile.length() < 6) { - throw new ServiceException("手机号长度不足,无法同步系统用户"); + throw new ServiceException("手机号长度不足,无法同步小程序用户"); } - // 编辑时前端可能不传 userId,从库中补齐已关联用户 + // 编辑时前端可能不传 userId,从库中补齐已关联小程序用户 if (Func.isEmpty(driver.getUserId()) && Func.isNotEmpty(driver.getId())) { Driver dbDriver = getById(driver.getId()); if (dbDriver != null && Func.isNotEmpty(dbDriver.getUserId())) { @@ -329,95 +323,83 @@ public class DriverServiceImpl extends BaseServiceImpl imp } } - R roleResult = sysClient.getRoleIdByAlias(tenantId, "driver"); - if (roleResult == null || !R.isSuccess(roleResult) || Func.isEmpty(roleResult.getData())) { - throw new ServiceException("未配置角色别名为driver的角色,请先在系统角色中维护"); - } - String roleId = roleResult.getData(); - + // 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步 + String deptId = null; R deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName()); - if (deptResult == null || !R.isSuccess(deptResult) || Func.isEmpty(deptResult.getData())) { - throw new ServiceException("所属组织未匹配到系统部门,无法同步用户"); + if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) { + deptId = deptResult.getData(); } - String deptId = deptResult.getData(); - User existing = findExistingUser(driver, tenantId, mobile); + MpUser existing = findExistingMpUser(driver, tenantId, mobile); if (existing == null || Func.isEmpty(existing.getId())) { - // 司机未关联有效用户时新建;初始密码由系统用户保存逻辑按默认参数/强度规则填充 - createAndLinkDriverUser(driver, tenantId, mobile, roleId, deptId); + createAndLinkDriverMpUser(driver, tenantId, mobile, deptId); } else { - updateAndLinkDriverUser(driver, existing, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existing, tenantId, mobile, deptId); } } - private User findExistingUser(Driver driver, String tenantId, String mobile) { + private MpUser findExistingMpUser(Driver driver, String tenantId, String mobile) { if (Func.isNotEmpty(driver.getUserId())) { - R byId = userClient.userInfoById(driver.getUserId()); + R byId = mpUserClient.userById(driver.getUserId()); if (byId != null && R.isSuccess(byId) && byId.getData() != null && Func.isNotEmpty(byId.getData().getId())) { return byId.getData(); } - // 关联的用户已不存在,清空后按手机号重建 + // 关联的小程序用户已不存在,清空后按手机号重建 driver.setUserId(null); } - return findExistingUserByAccountOrPhone(tenantId, mobile); + return findExistingMpUserByPhone(tenantId, mobile); } - private User findExistingUserByAccountOrPhone(String tenantId, String mobile) { - R byAccount = userClient.userByAccount(tenantId, mobile); - if (byAccount != null && R.isSuccess(byAccount) && byAccount.getData() != null - && Func.isNotEmpty(byAccount.getData().getId())) { - return byAccount.getData(); - } - R byPhone = userClient.userInfoByPhone(tenantId, mobile, UserType.WEB.getName()); + private MpUser findExistingMpUserByPhone(String tenantId, String mobile) { + R byPhone = mpUserClient.userByPhone(tenantId, mobile); if (byPhone != null && R.isSuccess(byPhone) && byPhone.getData() != null - && byPhone.getData().getUser() != null && Func.isNotEmpty(byPhone.getData().getUser().getId())) { - return byPhone.getData().getUser(); + && Func.isNotEmpty(byPhone.getData().getId())) { + return byPhone.getData(); } return null; } - private void createAndLinkDriverUser(Driver driver, String tenantId, String mobile, String roleId, String deptId) { - User user = buildSyncUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId); - // 不传密码:交由 UserServiceImpl.saveUser 使用系统默认初始密码或自动生成合规密码 - user.setUserType(UserType.WEB.getCategory()); + private void createAndLinkDriverMpUser(Driver driver, String tenantId, String mobile, String deptId) { + MpUser user = buildSyncMpUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(), + deptId, MpUserIdentity.DRIVER.getCode()); - R saveResult = null; + R saveResult; try { - saveResult = userClient.saveUser(user); + saveResult = mpUserClient.saveUser(user); } catch (Exception ex) { - User existed = findExistingUserByAccountOrPhone(tenantId, mobile); + MpUser existed = findExistingMpUserByPhone(tenantId, mobile); if (existed != null) { - updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId); return; } - throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步系统用户失败"); + throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步小程序用户失败"); } if (!isFeignSuccess(saveResult)) { - User existed = findExistingUserByAccountOrPhone(tenantId, mobile); + MpUser existed = findExistingMpUserByPhone(tenantId, mobile); if (existed != null) { - updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId); return; } - throw new ServiceException(resolveFeignError(saveResult, "同步系统用户失败")); + throw new ServiceException(resolveFeignError(saveResult, "同步小程序用户失败")); } - R created = userClient.userByAccount(tenantId, mobile); - User createdUser = (created != null && R.isSuccess(created)) ? created.getData() : null; + MpUser createdUser = findExistingMpUserByPhone(tenantId, mobile); if (createdUser == null || Func.isEmpty(createdUser.getId())) { - createdUser = findExistingUserByAccountOrPhone(tenantId, mobile); - } - if (createdUser == null || Func.isEmpty(createdUser.getId())) { - throw new ServiceException("系统用户创建成功但无法回查用户ID"); + throw new ServiceException("小程序用户创建成功但无法回查用户ID"); } linkDriverUserId(driver, createdUser.getId()); } - private void updateAndLinkDriverUser(Driver driver, User existing, String tenantId, String mobile, String roleId, String deptId) { - User user = buildSyncUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId); - R updateResult = userClient.updateUser(user); - // updateById 无字段变化时可能返回 false,但业务上仍视为成功,以接口 code 为准 + private void updateAndLinkDriverMpUser(Driver driver, MpUser existing, String tenantId, String mobile, String deptId) { + // 保留已有管理员等身份,确保包含司机身份 + String identities = MpUserIdentity.merge(existing.getIdentities(), MpUserIdentity.DRIVER.getCode()); + MpUser user = buildSyncMpUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(), + deptId, identities); + // 更新时不覆盖 openid + user.setOpenid(null); + R updateResult = mpUserClient.updateUser(user); if (!isFeignSuccess(updateResult)) { - throw new ServiceException(resolveFeignError(updateResult, "同步更新系统用户失败")); + throw new ServiceException(resolveFeignError(updateResult, "同步更新小程序用户失败")); } linkDriverUserId(driver, existing.getId()); } @@ -438,9 +420,9 @@ public class DriverServiceImpl extends BaseServiceImpl imp return msg; } - private User buildSyncUser(Long userId, String tenantId, String mobile, String driverName, String gender, - String roleId, String deptId) { - User user = new User(); + private MpUser buildSyncMpUser(Long userId, String tenantId, String mobile, String driverName, String gender, + String deptId, String identities) { + MpUser user = new MpUser(); user.setId(userId); user.setTenantId(tenantId); user.setAccount(mobile); @@ -448,8 +430,8 @@ public class DriverServiceImpl extends BaseServiceImpl imp user.setName(driverName); user.setRealName(driverName); user.setSex(UserSex.getCodeByName(gender)); - user.setRoleId(roleId); user.setDeptId(deptId); + user.setIdentities(identities); return user; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 2181abcf..9158ce0d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -31,12 +31,23 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.constant.BladeConstant; +import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.CertificationGate; +import org.springblade.transport.support.VehiclePlateSupport; import org.springblade.transport.pojo.entity.ProcessConfig; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; @@ -48,6 +59,7 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IProcessConfigService; @@ -67,8 +79,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -107,11 +119,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final DateTimeFormatter DATE_DOT_SHORT = DateTimeFormatter.ofPattern("MM.dd"); private static final DateTimeFormatter TIME_HM = DateTimeFormatter.ofPattern("HH:mm"); + /** 被异常阻断时的不可打卡原因(司机端展示) */ + private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡"; + private final IWaybillService waybillService; private final IDriverService driverService; + private final IMpUserClient mpUserClient; private final IProcessConfigService processConfigService; + private final ITransportVehicleService transportVehicleService; + private final CertificationGate certificationGate; private final WaybillEnroutePunchMapper enroutePunchMapper; private final WaybillNodePunchMapper nodePunchMapper; + private final ExceptionDisposalMapper exceptionDisposalMapper; @Override public DriverWaybillCardVO currentTask() { @@ -225,6 +244,52 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return toCard(normalizeAcceptStatus(waybill), true); } + @Override + public WaybillTrackVO track(Long id) { + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + LocalDate[] range = resolveTrackDateRange(waybill); + return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString()); + } + + /** + * 按打卡记录 / 计划时间推导轨迹查询起止日期 + */ + private LocalDate[] resolveTrackDateRange(Waybill waybill) { + LocalDate today = LocalDate.now(); + List punchDates = new ArrayList<>(); + nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybill.getId()) + .isNotNull(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getPunchTime)).forEach(punch -> { + if (punch.getPunchTime() != null) { + punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + } + }); + enroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybill.getId()) + .isNotNull(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getPunchTime)).forEach(punch -> { + if (punch.getPunchTime() != null) { + punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + } + }); + punchDates.sort(LocalDate::compareTo); + if (punchDates.size() > 1) { + return new LocalDate[]{punchDates.get(0), punchDates.get(punchDates.size() - 1)}; + } + if (punchDates.size() == 1) { + return new LocalDate[]{punchDates.get(0), today}; + } + if (waybill.getEstimatedStartTime() != null) { + return new LocalDate[]{waybill.getEstimatedStartTime(), today}; + } + if (waybill.getStartDate() != null) { + return new LocalDate[]{waybill.getStartDate(), today}; + } + return new LocalDate[]{today.minusDays(3), today}; + } + @Override public DriverWaybillCardVO detailPunchSnapshot(Long id) { if (id == null) { @@ -247,16 +312,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); + String processJson = resolveProcessJson(waybill); Date lastPunchAt = findLastPunchTime(waybill.getId()); WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin( - resolveProcessJson(waybill), waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); + processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); if (!decision.punchEnabled()) { throw new ServiceException("该运单未启用在途打卡"); } if (decision.doneToday()) { throw new ServiceException("今日已完成在途打卡,不可重复打卡"); } + // 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束 + assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.transitNodeKey(processJson)); // 不做频次/时段门禁;定位按前端是否传参落库 + assertPunchPhoto(dto.getPhoto() == null ? null : List.of(dto.getPhoto())); Date now = new Date(); WaybillEnroutePunch punch = new WaybillEnroutePunch(); @@ -292,7 +361,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); - Map nodeCfg = findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); + String processJson = resolveProcessJson(waybill); + Map nodeCfg = findPunchNodeConfig(processJson, nodeCode); if (nodeCfg == null) { throw new ServiceException("该节点未启用打卡或不存在"); } @@ -303,6 +373,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (existed != null) { throw new ServiceException("该节点已打卡,不可重复打卡"); } + assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg)); + assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg); + // photoItems 是新版小程序带的类型化照片;photos 是旧版仍在提交的裸 URL 列表, + // 类型只能按下标回推过程配置的凭证类型。两者并存是为了不强制升级小程序。 + boolean typedPhotoItems = dto.getPhotoItems() != null && !dto.getPhotoItems().isEmpty(); + List> photoItems = typedPhotoItems + ? typedNodePunchPhotos(dto.getPhotoItems(), WaybillProcessSupport.nodePhotoTypes(nodeCfg)) + : legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes")); + assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems); Date now = new Date(); WaybillNodePunch punch = new WaybillNodePunch(); @@ -321,24 +400,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim()); } - if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) { - List urls = dto.getPhotos().stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(Func::isNotEmpty) - .collect(Collectors.toList()); - if (!urls.isEmpty()) { - List types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"); - List> photoItems = new ArrayList<>(); - for (int i = 0; i < urls.size(); i++) { - Map item = new LinkedHashMap<>(); - String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1)); - item.put("type", type); - item.put("url", urls.get(i)); - photoItems.add(item); - } - punch.setPhotos(JsonUtil.toJson(photoItems)); - } + if (!photoItems.isEmpty()) { + punch.setPhotos(JsonUtil.toJson(photoItems)); } punch.setWeight(trimOrNull(dto.getWeight())); punch.setVolume(trimOrNull(dto.getVolume())); @@ -355,6 +418,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Transactional(rollbackFor = Exception.class) public boolean accept(Long id) { Driver driver = requireCurrentDriver(); + // 认证门槛:司机本人须已认证,且承运车辆须已认证。 + // 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。 + assertCertified(driver); Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); waybillService.syncDriverAcceptState(waybill); assertAcceptable(waybill); @@ -368,6 +434,40 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return waybillService.updateById(waybill); } + /** + * 接单前的认证门槛校验 + *

+ * 司机的生效快照由其档案持有;车辆按司机绑定的车牌取。 + * 任一不满足即拒绝接单,并给出可读原因。 + * + * @param driver 当前司机 + */ + private void assertCertified(Driver driver) { + if (!certificationGate.isDriverAvailable(driver.getEffectiveCertRecordId())) { + throw new ServiceException(certificationGate.driverUnavailableReason()); + } + if (currentBoundPlates(driver).isEmpty()) { + return; + } + // 用共享的车牌匹配条件:只做精确 IN 会漏掉库中带间隔符/大小写差异的车牌, + // 那会把已认证的司机误判为未认证而拒绝接单 + LambdaQueryWrapper plateQuery = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(plateQuery, currentBoundPlates(driver)); + List boundVehicles = transportVehicleService.list(plateQuery); + // 逐车取不可用原因:停用与未认证要分开报,否则「车已认证但被停用」 + // 会被报成「认证没通过」,把人引去做无用的重新认证 + String unavailableReason = boundVehicles.stream() + .map(vehicle -> certificationGate.vehicleUnavailableReason( + vehicle.getStatus() != null && vehicle.getStatus() == BladeConstant.DB_STATUS_NORMAL, + vehicle.getEffectiveCertRecordId())) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + if (unavailableReason != null) { + throw new ServiceException(unavailableReason); + } + } + @Override @Transactional(rollbackFor = Exception.class) public boolean reject(Long id, String reason) { @@ -404,6 +504,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); } + // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 + assertNoUnresolvedException(waybill.getId()); // 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单 return waybillService.completeWithoutDeptCheck(waybill.getId()); } @@ -539,7 +641,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 当前登录司机:优先 userId,其次 JWT 账号(手机号)匹配 driver.mobile + * 当前登录司机:优先 userId,其次手机号(JWT 账号 / 小程序用户 account)匹配 driver.mobile */ private Driver currentDriver() { Long userId = AuthUtil.getUserId(); @@ -552,37 +654,38 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (driver != null) { return driver; } - String account = AuthUtil.getUserAccount(); - if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + String phone = resolveSelfPhone(userId); + if (Func.isNotEmpty(phone)) { return driverService.getOne(Wrappers.lambdaQuery() - .eq(Driver::getMobile, account.trim()) + .eq(Driver::getMobile, phone) .orderByDesc(Driver::getUpdateTime) .last("LIMIT 1")); } return null; } - private List splitPlates(String drivingVehicle) { - String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",") - .replace(";", ",").replace("/", ",").replace("|", ","); - Set plates = new LinkedHashSet<>(); - for (String part : Func.toStrList(",", normalized)) { - if (Func.isEmpty(part)) { - continue; - } - String plate = normalizePlate(part); - if (Func.isNotEmpty(plate)) { - plates.add(plate); - } + /** + * 解析当前小程序用户可用于匹配司机档案的手机号 + */ + private String resolveSelfPhone(Long userId) { + String account = AuthUtil.getUserAccount(); + if (isMobileLike(account)) { + return account.trim(); } - return new ArrayList<>(plates); + R result = mpUserClient.userById(userId); + if (result != null && R.isSuccess(result) && result.getData() != null + && isMobileLike(result.getData().getAccount())) { + return result.getData().getAccount().trim(); + } + return Func.isEmpty(account) ? null : account.trim(); } - private String normalizePlate(String plateNo) { - if (Func.isEmpty(plateNo)) { - return ""; - } - return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT); + private boolean isMobileLike(String value) { + return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); + } + + private List splitPlates(String drivingVehicle) { + return VehiclePlateSupport.splitPlates(drivingVehicle); } private int normalizePreviewSize(Integer size) { @@ -746,11 +849,266 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .anyMatch(projectId::equals); } + /** + * 该运单未了结(未达终态)的异常处置所属节点 key。 + *

+ * 含空串表示存在未指明节点的异常(从全局入口上报),此类异常阻断该运单全部尚未打卡的节点。 + */ + @Override + public Map findPunchNode(Long waybillId, String nodeCode) { + if (waybillId == null || Func.isEmpty(nodeCode)) { + return null; + } + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + return null; + } + return findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); + } + + @Override + public boolean isNodePunched(Long waybillId, String nodeCode) { + if (waybillId == null || Func.isEmpty(nodeCode)) { + return false; + } + // 不复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置 key 比不出来 + return nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)) + .stream() + .anyMatch(punch -> WaybillProcessSupport.sameNodeCode(nodeCode, punch.getNodeCode())); + } + + @Override + public void assertCurrentDriverOwnsWaybill(Long waybillId) { + // 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定 + Driver driver = requireCurrentDriver(); + loadDriverWaybill(waybillId, currentBoundPlates(driver)); + } + + /** + * 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。 + *

+ * 打卡门禁与司机端的「异常」标签共用此口径,故两者永远一致: + * 只要标签还在,该节点就是被拦的;异常结案后标签与阻断同时消失。 + *

+ * 不含空串——未指明节点的异常没有对应节点,无从挂标签; + * 它是否阻断全单由 {@link #blockingException} 另行判定。 + */ + private Set unresolvedExceptionNodeCodes(Long waybillId) { + if (waybillId == null) { + return Collections.emptySet(); + } + return unresolvedExceptions(waybillId).stream() + .map(item -> Func.toStr(item.getNodeCode(), "").trim()) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + } + + /** + * 该运单未处理完成异常的节点 key 列表,**保留空串**(表示存在未指明节点的异常)。 + *

+ * 门禁判定需要知道「有没有未指明节点的异常」,故不能用上面那个已过滤空串的集合。 + */ + private List unresolvedExceptionNodeCodeList(Long waybillId) { + return unresolvedExceptions(waybillId).stream() + .map(item -> Func.toStr(item.getNodeCode(), "").trim()) + .collect(Collectors.toList()); + } + + /** 该运单未了结(未达终态)的异常处置,按上报时间升序 */ + private List unresolvedExceptions(Long waybillId) { + if (waybillId == null) { + return Collections.emptyList(); + } + return exceptionDisposalMapper.selectList(Wrappers.lambdaQuery() + .eq(ExceptionDisposal::getWaybillId, waybillId) + .eq(ExceptionDisposal::getIsDeleted, 0) + .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES) + .orderByAsc(ExceptionDisposal::getReportTime) + .orderByAsc(ExceptionDisposal::getId)); + } + + /** + * 取「正在阻断该运单打卡」的那条异常处置。 + *

+ * 多条未了结异常时取阻断区间最大的那条(最早节点优先、未指明节点最优先), + * 与 {@link WaybillProcessSupport#blockedNodeKeys} 的判定口径一致。 + */ + private ExceptionDisposal blockingException(Waybill waybill, String processJson) { + String earliestCode = WaybillProcessSupport.earliestBlockingNodeCode( + processJson, unresolvedExceptionNodeCodeList(waybill.getId())); + if (earliestCode == null) { + return null; + } + return unresolvedExceptions(waybill.getId()).stream() + .filter(item -> earliestCode.equals(Func.toStr(item.getNodeCode(), "").trim())) + .findFirst() + .orElse(null); + } + + /** + * 断言指定节点未被异常门禁阻断。 + *

+ * 普通节点:阻断范围是未了结异常所在节点及其后的节点。 + * 在途节点:该运单存在任何未了结异常即被阻断,与异常落在哪个节点无关。 + * 前端禁用只是体验层,这里才是强制点。 + */ + private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) { + Set blocked = WaybillProcessSupport.blockedNodeKeys( + processJson, unresolvedExceptionNodeCodeList(waybillId)); + if (Func.isNotEmpty(nodeCode) && blocked.contains(nodeCode)) { + throw new ServiceException("该运单存在未处理异常,处理完成后才能继续打卡"); + } + } + + /** + * 断言该节点的前置打卡节点都已完成(顺序门禁)。 + *

+ * 与异常门禁并列的第二道约束:两者解除条件不同,故分别校验、各自报错, + * 司机才知道该等一等(顺序)还是该找人处理(异常)。 + */ + private void assertPrerequisitePunched(String processJson, Long waybillId, Map nodeCfg) { + // ⚠️ 不能复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置的 key 比不出来 + Set punched = nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)) + .stream() + .map(punch -> Func.toStr(punch.getNodeCode(), "").trim()) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg)); + if (!unfinished.isEmpty()) { + throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + } + } + + /** + * 断言打卡照片已上传(在途链路)。 + *

+ * 在途只有一张照片、不带类型,故只判有没有;此处拦的是绕过客户端直接调接口的情况 + * ——前端的必填提示不构成约束。 + */ + private void assertPunchPhoto(List photos) { + boolean hasPhoto = photos != null && photos.stream() + .filter(Objects::nonNull) + .map(String::trim) + .anyMatch(Func::isNotEmpty); + if (!hasPhoto) { + throw new ServiceException("请上传打卡照片"); + } + } + + /** + * 节点打卡的显式类型照片:类型由提交方给出,超出该节点取值域的直接拒绝。 + *

+ * 取值域是固定的「打卡照片」加过程配置勾选的凭证类型;类型是照片分组的唯一依据, + * 放进来路不明的类型会让回显分组错乱。 + */ + private List> typedNodePunchPhotos(List items, List allowedTypes) { + List> out = new ArrayList<>(); + for (PunchPhotoDTO item : items) { + if (item == null) { + continue; + } + String url = Func.toStr(item.getUrl(), "").trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = Func.toStr(item.getType(), "").trim(); + if (Func.isEmpty(type)) { + throw new ServiceException("照片类型不能为空"); + } + if (!allowedTypes.contains(type)) { + throw new ServiceException("照片类型「" + type + "」不属于该节点"); + } + out.add(nodePunchPhotoItem(type, url)); + } + return out; + } + + /** + * 节点打卡的旧版照片:裸 URL 列表,类型按下标取该节点的凭证类型,超出补「凭证N」。 + */ + private List> legacyNodePunchPhotos(List photos, List voucherTypes) { + List> out = new ArrayList<>(); + if (photos == null || photos.isEmpty()) { + return out; + } + List urls = photos.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(Func::isNotEmpty) + .collect(Collectors.toList()); + for (int i = 0; i < urls.size(); i++) { + String type = i < voucherTypes.size() ? voucherTypes.get(i) : ("凭证" + (i + 1)); + out.add(nodePunchPhotoItem(type, urls.get(i))); + } + return out; + } + + private Map nodePunchPhotoItem(String type, String url) { + Map item = new LinkedHashMap<>(); + item.put("type", type); + item.put("url", url); + return item; + } + + /** + * 断言节点打卡照片满足类型口径。 + *

+ * 每个类型至多 9 张,按类型各自计数,不是节点合计。 + * 新版提交(photoItems)还需每个类型至少 1 张,含固定的「打卡照片」; + * 旧版只提交裸 URL 列表,类型按下标回推、表达不出「打卡照片」, + * 对它套用「每个类型至少 1 张」会让所有未升级的小程序提交失败,故旧版仍只要求至少有 1 张。 + */ + private void assertNodePunchPhotos(List> photoItems, Map nodeCfg, + boolean typedPhotoItems) { + Map countByType = countPunchPhotoByType(photoItems); + for (Map.Entry entry : countByType.entrySet()) { + if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) { + throw new ServiceException("「" + entry.getKey() + "」最多上传 " + + WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片"); + } + } + if (!typedPhotoItems) { + if (countByType.isEmpty()) { + throw new ServiceException("请上传打卡照片"); + } + return; + } + for (String type : WaybillProcessSupport.nodePhotoTypes(nodeCfg)) { + if (countByType.getOrDefault(type, 0) < 1) { + throw new ServiceException("「" + type + "」至少上传 1 张照片"); + } + } + } + + private Map countPunchPhotoByType(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + return countByType; + } + + /** + * 断言该运单没有未了结的异常。 + *

+ * 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从 + * 「完成运单」绕过打卡门禁。 + */ + private void assertNoUnresolvedException(Long waybillId) { + if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) { + throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单"); + } + } + /** * 组装过程配置 punch=是 的打卡节点列表。 *

- * 未打卡节点均可打,不做顺序/时段门禁; - * 默认展开:第一个未完成的可见节点。 + * 两道跨节点门禁:异常门禁(未了结异常所在节点及其后节点不可打卡,在途另按运单级—— + * 该运单有任何未了结异常就不可打卡)、顺序门禁(前置打卡节点未完成则不可打卡,在途豁免)。 + * 默认展开:第一个未完成、且未被异常阻断的可见节点。 * 非在途节点「已打卡」以节点打卡表为准。 * 节点字段(定位/货量/凭证)取自动态过程配置。 */ @@ -837,8 +1195,87 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodes.add(vo); } + // 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡; + // 在途另按运单级——该运单只要还有未了结异常,在途就不可打卡。 + // 已打卡节点不受影响,仍可展开查看回填内容。 + ExceptionDisposal blocker = blockingException(waybill, processJson); + Set blockedKeys = WaybillProcessSupport.blockedNodeKeys( + processJson, unresolvedExceptionNodeCodeList(waybill.getId())); + if (!blockedKeys.isEmpty()) { + for (DriverPunchNodeVO vo : nodes) { + if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) { + continue; + } + vo.setBlocked(true); + vo.setActionable(false); + if (blocker != null) { + vo.setBlockedExceptionId(blocker.getId()); + } + vo.setLockReason(LOCK_REASON_EXCEPTION); + } + } + + // 顺序门禁:前面还有打卡节点没打,本节点就不能打(在途豁免)。 + // 与异常门禁独立:两者可同时成立,也可只成立一个。 + Set punchedCodes = new HashSet<>(); for (DriverPunchNodeVO vo : nodes) { - if (!Boolean.TRUE.equals(vo.getDone())) { + if (Boolean.TRUE.equals(vo.getDone())) { + punchedCodes.add(vo.getKey()); + } + } + for (DriverPunchNodeVO vo : nodes) { + if (Boolean.TRUE.equals(vo.getDone())) { + continue; + } + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + processJson, punchedCodes, vo.getKey()); + if (unfinished.isEmpty()) { + continue; + } + vo.setBlockedByOrder(true); + vo.setActionable(false); + vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + } + + // 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用); + // 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。 + for (DriverPunchNodeVO vo : nodes) { + boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder()); + vo.setExpandable(!blockedByOrder); + } + + // 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。 + // 被牵连阻断的后续节点没有异常上报,不挂标签——否则司机以为每个节点都出了事。 + Set exceptionNodeCodes = unresolvedExceptionNodeCodes(waybill.getId()); + if (!exceptionNodeCodes.isEmpty()) { + for (DriverPunchNodeVO vo : nodes) { + if (exceptionNodeCodes.contains(vo.getKey())) { + vo.setHasException(true); + } + } + } + + // 「整单异常」标签与相关异常 id:整单异常不归属任何节点(节点编码为空), + // 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。 + // 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点 + // 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。 + // 未了结异常已按上报时间升序取出,过滤后仍保持该顺序;id 转字符串是因为主键是 int64, + // 前端直收数字会丢精度。 + List unresolved = unresolvedExceptions(waybill.getId()); + boolean hasWaybillException = unresolved.stream() + .anyMatch(item -> WaybillProcessSupport.isWaybillLevelException(item.getNodeCode())); + for (DriverPunchNodeVO vo : nodes) { + vo.setHasWaybillException(hasWaybillException); + vo.setRelatedExceptionIds(unresolved.stream() + .filter(item -> WaybillProcessSupport.relatesToNode(vo.getKey(), item.getNodeCode())) + .map(item -> String.valueOf(item.getId())) + .collect(Collectors.toList())); + } + + for (DriverPunchNodeVO vo : nodes) { + if (!Boolean.TRUE.equals(vo.getDone()) + && !Boolean.TRUE.equals(vo.getBlocked()) + && !Boolean.TRUE.equals(vo.getBlockedByOrder())) { vo.setDefaultExpanded(true); break; } @@ -846,6 +1283,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return nodes; } + /** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */ + private String prerequisiteNames(String processJson, Set nodeKeys) { + List names = new ArrayList<>(); + for (Map node : WaybillProcessSupport.listDriverPunchNodes(processJson)) { + String key = WaybillProcessSupport.nodeKey(node); + if (nodeKeys.contains(key)) { + names.add(WaybillProcessSupport.nodeName(node)); + } + } + return names.isEmpty() ? "" : String.join("、", names); + } + private Map findPunchNodeConfig(String processJson, String nodeCode) { List> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); for (Map cfg : punchConfigs) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 008e827e..d86e008c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -31,25 +31,30 @@ import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +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.Waybill; import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IExceptionDisposalService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -68,13 +73,19 @@ public class ExceptionDisposalServiceImpl private static final String STATUS_PROCESSING = "processing"; private static final String STATUS_COMPLETED = "completed"; + /** latest_follow_content 与 follow_content 两列均为 varchar(500) */ + private static final int FOLLOW_CONTENT_MAX_LENGTH = 500; + private final ExceptionDisposalFollowRecordMapper followRecordMapper; private final IWaybillService waybillService; + private final IDriverWaybillService driverWaybillService; public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper, - IWaybillService waybillService) { + IWaybillService waybillService, + IDriverWaybillService driverWaybillService) { this.followRecordMapper = followRecordMapper; this.waybillService = waybillService; + this.driverWaybillService = driverWaybillService; } @Override @@ -120,7 +131,7 @@ public class ExceptionDisposalServiceImpl disposal.setDisposalStatus(STATUS_PENDING); disposal.setReportTime(LocalDateTime.now()); disposal.setReporterId(AuthUtil.getUserId()); - disposal.setReporterName(UserCache.getUserRealName(AuthUtil.getUserId())); + disposal.setReporterName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); fillFromWaybill(disposal, request.getWaybillId(), request.getWaybillNo()); @@ -128,12 +139,58 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请关联运单后再上报"); } + // 上报人只可能是该运单的承运司机本人,取登录态;请求体里的上报人一律忽略。 + // 归属按司机绑定车牌判定,与司机打卡、司机接单同一口径。 + // 整单异常同样受这条约束:没有节点不等于没有上报人。 + driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId()); + + // 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节, + // 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。 + // 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。 + String nodeCode = Func.toStr(request.getNodeCode(), "").trim(); + if (Func.isNotEmpty(nodeCode)) { + Map nodeCfg = driverWaybillService.findPunchNode(disposal.getWaybillId(), nodeCode); + if (nodeCfg == null) { + throw new ServiceException("异常节点不存在"); + } + String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + // 上报门禁之一:已打卡说明这个环节已经过去,事后才发现的问题走整单异常。 + // 在途例外——它可重复打卡、没有完成含义,打卡与否不说明环节是否已经过去。 + if (!WaybillProcessSupport.isTransitNodePublic(nodeCfg) + && driverWaybillService.isNodePunched(disposal.getWaybillId(), nodeKey)) { + throw new ServiceException("该节点已打卡,不能上报异常"); + } + // 上报门禁之二:同一节点同时至多一条未了结异常,处理中也占位。 + // 约束是同时性的:异常了结后该节点重新可以上报。 + if (hasUnresolvedNodeException(disposal.getWaybillId(), nodeKey)) { + throw new ServiceException("该节点已有未处理异常,处理完成后才能再次上报"); + } + disposal.setNodeCode(nodeKey); + disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg)); + } + if (!save(disposal)) { throw new ServiceException("异常上报失败"); } return toVO(disposal); } + /** + * 该运单的指定节点是否已有未了结(未达终态)的异常。 + *

+ * 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算, + * 「处理中」同样占位——它只是有人跟进过,问题可能仍在。 + * 节点编码忽略大小写与首尾空白:历史数据里的写法不一定与过程配置的 key 完全一致。 + */ + private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) { + Long count = baseMapper.selectCount(Wrappers.lambdaQuery() + .eq(ExceptionDisposal::getWaybillId, waybillId) + .eq(ExceptionDisposal::getIsDeleted, 0) + .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES) + .apply("LOWER(TRIM(node_code)) = {0}", nodeCode.trim().toLowerCase(Locale.ROOT))); + return count != null && count > 0; + } + /** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */ private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) { Waybill waybill = null; @@ -165,31 +222,129 @@ public class ExceptionDisposalServiceImpl disposal.setLoadingOrMasterNo(loadingOrMaster); } + @Override + @Transactional(rollbackFor = Exception.class) + public void update(ExceptionDisposalUpdateRequest request) { + if (request == null || request.getId() == null) { + throw new ServiceException("请选择需要编辑的异常"); + } + if (Func.isBlank(request.getExceptionType())) { + throw new ServiceException("请选择异常类型"); + } + if (Func.isBlank(request.getReportDescription())) { + throw new ServiceException("请填写上报说明"); + } + if (request.getReportDescription().length() > 500) { + throw new ServiceException("上报说明不能超过500字"); + } + if (Func.isNotBlank(request.getExceptionReason()) && request.getExceptionReason().length() > 200) { + throw new ServiceException("异常原因不能超过200字"); + } + ExceptionDisposal disposal = getExisting(request.getId()); + + String exceptionType = request.getExceptionType().trim(); + String exceptionReason = Func.isBlank(request.getExceptionReason()) + ? null + : request.getExceptionReason().trim(); + String reportDescription = request.getReportDescription().trim(); + String scenePhotos = Func.isBlank(request.getScenePhotos()) + ? null + : request.getScenePhotos().trim(); + + String changeLog = buildChangeLog(disposal, exceptionType, exceptionReason, reportDescription, scenePhotos); + if (Func.isBlank(changeLog)) { + throw new ServiceException("未检测到任何修改"); + } + + disposal.setExceptionType(exceptionType); + disposal.setExceptionReason(exceptionReason); + disposal.setReportDescription(reportDescription); + disposal.setScenePhotos(scenePhotos); + disposal.setLatestFollowContent(changeLog); + updateById(disposal); + + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposal.getId()); + record.setFollowContent(changeLog); + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + } + + /** + * 逐字段比对生成变更说明,用于写入跟进记录。 + * 未发生变更时返回 null,调用方据此拒绝这次编辑。 + *

+ * 结果长度受 latest_follow_content / follow_content 的 varchar(500) 限制, + * 而变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超限,故须截断。 + */ + private String buildChangeLog(ExceptionDisposal before, String exceptionType, String exceptionReason, + String reportDescription, String scenePhotos) { + List changes = new ArrayList<>(); + appendChange(changes, "异常类型", before.getExceptionType(), exceptionType); + appendChange(changes, "异常原因", before.getExceptionReason(), exceptionReason); + appendChange(changes, "上报说明", before.getReportDescription(), reportDescription); + if (!Objects.equals(before.getScenePhotos(), scenePhotos)) { + changes.add("现场照片由 " + photoCount(before.getScenePhotos()) + " 张改为 " + photoCount(scenePhotos) + " 张"); + } + if (changes.isEmpty()) { + return null; + } + return truncate("编辑:" + String.join(";", changes)); + } + + private void appendChange(List changes, String label, String before, String after) { + String oldValue = Func.isBlank(before) ? "空" : before; + String newValue = Func.isBlank(after) ? "空" : after; + if (!Objects.equals(before, after)) { + changes.add(label + "由 " + oldValue + " 改为 " + newValue); + } + } + + /** 截断到字段上限内,留出省略号位置 */ + private String truncate(String text) { + return text.length() <= FOLLOW_CONTENT_MAX_LENGTH + ? text + : text.substring(0, FOLLOW_CONTENT_MAX_LENGTH - 1) + "…"; + } + + private int photoCount(String scenePhotos) { + return splitPhotos(scenePhotos).size(); + } + @Override @Transactional(rollbackFor = Exception.class) public void follow(ExceptionDisposalFollowRequest request) { if (request == null || request.getId() == null) { throw new ServiceException("请选择需要跟进的异常"); } - if (Func.isBlank(request.getFollowContent())) { - throw new ServiceException("请填写跟进说明"); + boolean hasAttachments = Func.isNotBlank(request.getAttachmentsJson()); + if (Func.isBlank(request.getFollowContent()) && !hasAttachments) { + throw new ServiceException("请填写跟进说明或上传附件"); } - if (request.getFollowContent().length() > 500) { + if (Func.isNotBlank(request.getFollowContent()) && request.getFollowContent().length() > 500) { throw new ServiceException("跟进说明不能超过500字"); } ExceptionDisposal disposal = getExisting(request.getId()); if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) { throw new ServiceException("已完成的异常不允许继续跟进"); } + // 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示 + String followContent = Func.isBlank(request.getFollowContent()) + ? null + : request.getFollowContent(); disposal.setDisposalStatus(STATUS_PROCESSING); - disposal.setLatestFollowContent(request.getFollowContent()); + disposal.setLatestFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent); updateById(disposal); ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); record.setDisposalId(disposal.getId()); - record.setFollowContent(request.getFollowContent()); + // follow_content 列为 NOT NULL + record.setFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent); + record.setAttachmentsJson(hasAttachments ? request.getAttachmentsJson() : null); record.setFollowUser(AuthUtil.getUserId()); - record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); record.setFollowTime(LocalDateTime.now()); followRecordMapper.insert(record); } @@ -231,6 +386,9 @@ public class ExceptionDisposalServiceImpl private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ExceptionDisposal::getIsDeleted, 0) + // 按运单精确过滤,供司机端「本运单的异常」列表使用。 + // 不用运单号:它是模糊匹配,YD001 会命中 YD0011。 + .eq(query.getWaybillId() != null, ExceptionDisposal::getWaybillId, query.getWaybillId()) .like(Func.isNotEmpty(query.getWaybillNo()), ExceptionDisposal::getWaybillNo, query.getWaybillNo()) .like(Func.isNotEmpty(query.getVehicleNo()), ExceptionDisposal::getVehicleNo, query.getVehicleNo()) .like(Func.isNotEmpty(query.getReporterName()), ExceptionDisposal::getReporterName, query.getReporterName()) @@ -259,8 +417,8 @@ public class ExceptionDisposalServiceImpl if (vo == null) { return null; } - vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); - vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); + vo.setCreateUserName(UserDisplayCache.getUserRealName(entity.getCreateUser())); + vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser())); vo.setDisposalStatusName(statusName(entity.getDisposalStatus())); vo.setScenePhotoList(splitPhotos(entity.getScenePhotos())); fillRouteAndCargo(vo, entity.getWaybillId()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 5854689f..71327729 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -30,7 +30,7 @@ import lombok.RequiredArgsConstructor; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.RiskDisposal; @@ -57,6 +57,7 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashSet; @@ -85,6 +86,13 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private static final String DISPOSAL_PROCESSING = "processing"; private static final String RISK_PENDING = "pending"; + /** 小程序首页待处理事项的类型:异常待处置 */ + private static final String TODO_EXCEPTION = "exception"; + /** 小程序首页待处理事项的类型:重新派单待处理 */ + private static final String TODO_REASSIGN = "reassign"; + /** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */ + private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0"; + private static final String EXCEPTION_YES = "exception"; private static final String EXCEPTION_NO = "normal"; private static final String TRANSPORT_COMMON = "common"; @@ -122,8 +130,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); badges.setException(home.getStats().getException()); badges.setRisk(countPendingRisks()); + badges.setPendingReassign(countPendingReassignments()); home.setBadges(badges); - home.setFeed(buildExceptionFeed()); + home.setFeed(buildAdminFeed()); return home; } @@ -561,18 +570,66 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .eq(RiskDisposal::getDisposalStatus, RISK_PENDING)); } - private List buildExceptionFeed() { + /** 待重新派单:司机已拒单,且运单尚未取消/完成 */ + private long countPendingReassignments() { + return waybillService.count(pendingReassignQuery()); + } + + private LambdaQueryWrapper pendingReassignQuery() { + return Wrappers.lambdaQuery() + .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) + .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); + } + + /** + * 首页待处理事项:异常待处置 + 重新派单待处理。 + * 两类各按自己的业务发生时间(上报时刻 / 拒单时刻)倒序,合并后再整体倒序取前 N 条。 + * 排序时间随条目一起携带,不塞进 id 字段——id 在两型下各自是处置主键与运单主键。 + */ + List buildAdminFeed() { + List merged = new ArrayList<>(); + merged.addAll(buildExceptionFeed()); + merged.addAll(buildReassignFeed()); + merged.sort(java.util.Comparator.comparing( + (TimedTodo entry) -> entry.time, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder()))); + java.util.Collections.reverse(merged); + int to = Math.min(merged.size(), FEED_LIMIT); + return merged.subList(0, to).stream().map(TimedTodo::item).collect(Collectors.toList()); + } + + /** 待处理事项 + 它的业务发生时间(仅用于排序) */ + private record TimedTodo(AdminTodoItemVO item, LocalDateTime time) { + } + + private List buildExceptionFeed() { List list = exceptionDisposalService.list(Wrappers.lambdaQuery() .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) .orderByDesc(ExceptionDisposal::getReportTime) .last("LIMIT " + FEED_LIMIT)); - return list.stream().map(this::toTodoItem).collect(Collectors.toList()); + return list.stream() + .map(disposal -> new TimedTodo(toTodoItem(disposal), exceptionTimeOf(disposal))) + .collect(Collectors.toList()); + } + + private List buildReassignFeed() { + List list = waybillService.list(pendingReassignQuery() + .orderByDesc(Waybill::getDriverRejectTime) + .last("LIMIT " + FEED_LIMIT)); + return list.stream() + .map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill))) + .collect(Collectors.toList()); + } + + private LocalDateTime exceptionTimeOf(ExceptionDisposal disposal) { + return disposal.getReportTime() != null + ? disposal.getReportTime() + : toLocalDateTime(disposal.getCreateTime()); } private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) { AdminTodoItemVO item = new AdminTodoItemVO(); item.setId(disposal.getId()); - item.setType("exception"); + item.setType(TODO_EXCEPTION); item.setTitle("异常待处置"); item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null ? disposal.getReportTime() @@ -585,6 +642,38 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return item; } + private AdminTodoItemVO toReassignTodoItem(Waybill waybill) { + AdminTodoItemVO item = new AdminTodoItemVO(); + item.setId(waybill.getId()); + item.setType(TODO_REASSIGN); + item.setTitle("重新派单待处理"); + item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill))); + item.setDesc(buildReassignDesc(waybill)); + item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), "")); + item.setActionLabel("重新派单"); + item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId())); + return item; + } + + /** 拒单时刻是「被拒了多久」的唯一依据;为空(历史数据)才回退到最后修改时间 */ + private LocalDateTime rejectTimeOf(Waybill waybill) { + if (waybill.getDriverRejectTime() != null) { + return toLocalDateTime(waybill.getDriverRejectTime()); + } + return toLocalDateTime(waybill.getUpdateTime()); + } + + /** 拒单原因的输入是可选的,没填时正文不能留下空的「:」 */ + private String buildReassignDesc(Waybill waybill) { + String driver = Func.toStr(waybill.getDriverName(), "原司机"); + String text = "运单已指派" + driver + ",被拒绝,请重新派单"; + String reason = Func.toStr(waybill.getDriverRejectReason(), "").trim(); + if (Func.isNotBlank(reason)) { + text = text + ":" + reason; + } + return text.length() > 80 ? text.substring(0, 80) + "…" : text; + } + private String buildExceptionDesc(ExceptionDisposal disposal) { String reporter = Func.toStr(disposal.getReporterName(), "司机"); String type = Func.toStr(disposal.getExceptionType(), "异常"); @@ -599,13 +688,15 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } private String resolveUserName() { - String realName = UserCache.getUserRealName(AuthUtil.getUserId()); - if (Func.isNotBlank(realName)) { + String realName = UserDisplayCache.getUserRealName(AuthUtil.getUserId()); + if (Func.isNotBlank(realName) && !Func.toStr(AuthUtil.getUserId()).equals(realName)) { return realName; } - return Func.toStr(AuthUtil.getUserName(), ""); + String jwtName = Func.toStr(AuthUtil.getNickName(), AuthUtil.getUserName()); + return Func.toStr(jwtName, realName); } + /** 相对时间文案;时间取不到时留空,避免把「现在」当成发生时刻显示成「刚刚」 */ private String formatTimeAgo(LocalDateTime time) { if (time == null) { return ""; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java index e322414b..51491538 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java @@ -677,7 +677,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl dispatch) { return String.join("\u0000", string(dispatch, "segmentNo", ""), string(dispatch, "carrierType", ""), string(dispatch, "carrierContractId", ""), string(dispatch, "carrierName", ""), string(dispatch, "driverName", ""), string(dispatch, "driverPhone", ""), string(dispatch, "vehicleNo", ""), string(dispatch, "captainName", ""), string(dispatch, "containerNo", ""), string(dispatch, "cabinNo", "")); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index 7f80ae09..a03ae549 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -320,7 +320,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementSummaryFee::getPreSettlementId, settlement.getId())); } @@ -330,7 +330,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl 0) { + code = code.substring(0, separatorIndex).trim(); + } + if ("CNY".equals(code)) { + return LOCAL_CURRENCY; + } + return Func.isEmpty(code) ? LOCAL_CURRENCY : code; + } + private String resolveSettlementType(PreSettlementSaveRequest request, ContractManage contract) { if (Boolean.TRUE.equals(request.getAllowSourceMismatch()) && Func.isNotEmpty(request.getSettlementType())) { validateSettlementType(request.getSettlementType()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index 01dbd443..d0f06ea8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -338,19 +338,23 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl queryWrapper = Wrappers.lambdaQuery().eq(ProjectApply::getIsDeleted, 0); if (TransportBusinessSupport.isAdministrator()) { - // 超管默认可查看全部未删除数据;仅在明确指定承办组织时按组织精确筛选。 + // 超管默认可查看全部未删除数据;仅在明确指定平台公司时按包含匹配筛选。 if (Objects.equals(projectApply.getAllDept(), 1) && Func.isNotEmpty(projectApply.getUndertakeDeptId())) { - queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId()); + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); } } else if (!Objects.equals(projectApply.getAllDept(), 1)) { Long currentDeptId = TransportBusinessSupport.currentDeptId("项目立项"); Long currentUserId = AuthUtil.getUserId(); - queryWrapper.and(wrapper -> wrapper.eq(ProjectApply::getUndertakeDeptId, currentDeptId) - .or().eq(ProjectApply::getCreateUser, currentUserId) - .or().eq(ProjectApply::getHandlerUserId, currentUserId) - .or().eq(ProjectApply::getPrincipalUserId, currentUserId)); + queryWrapper.and(wrapper -> { + if (currentDeptId != null) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", String.valueOf(currentDeptId)); + } + wrapper.or().eq(ProjectApply::getCreateUser, currentUserId) + .or().eq(ProjectApply::getHandlerUserId, currentUserId) + .or().eq(ProjectApply::getPrincipalUserId, currentUserId); + }); } else if (Func.isNotEmpty(projectApply.getUndertakeDeptId())) { - queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId()); + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); } queryWrapper.like(Func.isNotEmpty(projectApply.getApplyNo()), ProjectApply::getApplyNo, projectApply.getApplyNo()) .like(Func.isNotEmpty(projectApply.getProjectCode()), ProjectApply::getProjectCode, projectApply.getProjectCode()) @@ -464,6 +468,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl 0) { - throw new ServiceException("项目应收账款额度不能超过项目资金使用额度"); - } validateDays(projectApply.getReceivableDays(), "应收账款回款期限"); validateDays(projectApply.getPaymentDays(), "回款账期"); validateAmount(projectApply.getProjectScale(), "项目规模", true); @@ -770,6 +775,34 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl situationMap)) { + TransportBusinessSupport.validateLength(situationRemark, 1000, "项目情况说明不能超过1000个字"); + return; + } + Map data = (Map) situationMap; + TransportBusinessSupport.validateLength(Func.toStr(data.get("projectIntro")), 1000, "项目简介不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("profitRemark")), 1000, "盈利模式及利润测算情况说明不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("riskPoint")), 1000, "项目风险点不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("emergencyPlan")), 1000, "风险防范措施及应急预案不能超过1000个字"); } private void validateChange(ProjectApply projectApply, String changeType) { @@ -956,10 +989,86 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl queryWrapper, String undertakeDeptId) { + if (Func.isBlank(undertakeDeptId) || queryWrapper == null) { + return; + } + List deptIds = Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + return; + } + queryWrapper.and(wrapper -> { + boolean first = true; + for (String deptId : deptIds) { + if (first) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + first = false; + } else { + wrapper.or().apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + } + } + }); + } + + private void normalizeUndertakeDept(ProjectApply projectApply) { + if (projectApply == null) { + return; + } + List deptIds = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptId()).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .distinct() + .toList(); + projectApply.setUndertakeDeptId(deptIds.isEmpty() ? null : String.join(",", deptIds)); + List deptNames = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptName()).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + projectApply.setUndertakeDeptName(deptNames.isEmpty() ? null : String.join("、", deptNames)); + } + + private Long firstUndertakeDeptId(String undertakeDeptId) { + return Arrays.stream(Func.toStr(undertakeDeptId).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .map(Func::toLong) + .filter(id -> id != null && id > 0L) + .findFirst() + .orElse(null); + } + + private String firstUndertakeDeptName(String undertakeDeptName) { + return Arrays.stream(Func.toStr(undertakeDeptName).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(null); + } + + private boolean containsUndertakeDept(String undertakeDeptId, Long deptId) { + if (deptId == null || Func.isBlank(undertakeDeptId)) { + return false; + } + String target = String.valueOf(deptId); + return Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .anyMatch(target::equals); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index 6f765d7c..382f925d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -80,6 +80,7 @@ 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; @@ -431,13 +432,23 @@ public class ReceivablePayableDetailServiceImpl @Override public IPage changeRecords(IPage page, Long detailId) { - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() - .eq(ReceivablePayableChangeRecord::getDetailId, detailId) - .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) - .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime); - IPage entityPage = changeRecordMapper.selectPage(new Page<>(page.getCurrent(), page.getSize()), wrapper); - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream().map(record -> Objects.requireNonNull( + List meaningfulRecords = changeRecordMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceivablePayableChangeRecord::getDetailId, detailId) + .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) + .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime)) + .stream() + .filter(record -> hasMeaningfulChange(record.getChangeContent())) + .toList(); + long current = page.getCurrent(); + long size = page.getSize(); + int fromIndex = (int) Math.max(0L, (current - 1L) * size); + int toIndex = (int) Math.min(meaningfulRecords.size(), fromIndex + size); + List pageRecords = fromIndex >= meaningfulRecords.size() + ? List.of() + : meaningfulRecords.subList(fromIndex, toIndex); + Page voPage = new Page<>(current, size, meaningfulRecords.size()); + voPage.setRecords(pageRecords.stream().map(record -> Objects.requireNonNull( BeanUtil.copyProperties(record, ReceivablePayableChangeRecordVO.class))).toList()); return voPage; } @@ -470,8 +481,10 @@ public class ReceivablePayableDetailServiceImpl } List afterRows = activeCargoFees(detail.getId()); List changes = reconcileUpdatedFeeAmounts(beforeRows, afterRows); + LocalDateTime adjustTime = LocalDateTime.now(); for (int index = 0; index < changes.size(); index++) { - saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), String.format("%04d", index + 1)); + saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), + String.format("%04d", index + 1), adjustTime); } } } @@ -612,14 +625,8 @@ public class ReceivablePayableDetailServiceImpl appendChange(changes, "运输单价", existing.getUnitPrice(), adjusted.getUnitPrice()); appendChange(changes, "里程", existing.getMileage(), mileage); appendChange(changes, "运输费", existing.getFreightAmount(), freightAmount); - if (!Objects.equals(existing.getRemark(), adjusted.getRemark())) { - changes.add("【备注】从[" + Objects.toString(existing.getRemark(), "") + "]调整为[" - + Objects.toString(adjusted.getRemark(), "") + "]"); - } - if (!Objects.equals(existing.getChangeReason(), adjusted.getChangeReason())) { - changes.add("【变更原因】从[" + Objects.toString(existing.getChangeReason(), "") + "]调整为[" - + Objects.toString(adjusted.getChangeReason(), "") + "]"); - } + appendChange(changes, "备注", existing.getRemark(), adjusted.getRemark()); + appendChange(changes, "变更原因", existing.getChangeReason(), adjusted.getChangeReason()); Map oldFeeItems = parseMap(existing.getFeeItemsJson()); for (String name : allowedFeeItems) { appendChange(changes, name, decimal(oldFeeItems.get(name)), money(effectiveFeeItems.get(name))); @@ -645,8 +652,9 @@ public class ReceivablePayableDetailServiceImpl if (changes.isEmpty()) { throw new ServiceException("未修改任何费用数据"); } + LocalDateTime adjustTime = LocalDateTime.now(); for (int i = 0; i < changes.size(); i++) { - saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1)); + saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1), adjustTime); } refreshAdjustedDetail(detail, allRows); } @@ -1471,7 +1479,7 @@ public class ReceivablePayableDetailServiceImpl detail.setMileage(normalizeGeneratedMileage(waybill.getMileage())); detail.setBatchNo(waybill.getBatchNo()); detail.setUnitPrice(unitPrice); - detail.setCurrency("RMB"); + detail.setCurrency(resolveCurrency(contract, waybill)); detail.setFreightAmount(freight); detail.setOtherFeeAmount(other); detail.setTotalAmount(total); @@ -2369,12 +2377,56 @@ public class ReceivablePayableDetailServiceImpl } private void appendChange(List changes, String field, String before, String after) { - if (!Objects.equals(before, after)) { - changes.add("【" + field + "】从[" + Objects.toString(before, "") + "]调整为[" - + Objects.toString(after, "") + "]"); + String oldValue = normalizeChangeText(before); + String newValue = normalizeChangeText(after); + if (!Objects.equals(oldValue, newValue)) { + changes.add("【" + field + "】从[" + oldValue + "]调整为[" + newValue + "]"); } } + /** + * 变更文本归一化:null / 空白视为相同,避免出现「从[]调整为[]」无效记录 + */ + private String normalizeChangeText(String value) { + return value == null ? "" : value.trim(); + } + + /** + * 是否存在实际前后差异;整段内容均无变化时不展示 + */ + private boolean hasMeaningfulChange(String content) { + if (Func.isBlank(content)) { + return false; + } + String text = content.trim(); + int suffixStart = text.lastIndexOf('('); + if (suffixStart > 0 && text.endsWith(")")) { + text = text.substring(0, suffixStart); + } + boolean hasPart = false; + for (String part : text.split(";")) { + if (Func.isBlank(part)) { + continue; + } + hasPart = true; + if (isMeaningfulChangePart(part.trim())) { + return true; + } + } + return !hasPart; + } + + private boolean isMeaningfulChangePart(String part) { + int fromIdx = part.indexOf("从["); + int toIdx = part.indexOf("]调整为["); + if (fromIdx < 0 || toIdx < fromIdx || !part.endsWith("]")) { + return true; + } + String before = part.substring(fromIdx + 2, toIdx); + String after = part.substring(toIdx + 5, part.length() - 1); + return !Objects.equals(before, after); + } + private String formatValue(BigDecimal value) { return money(value).stripTrailingZeros().toPlainString(); } @@ -2526,6 +2578,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, waybill)); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2557,6 +2610,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, masterGoods.isEmpty() ? null : masterGoods.get(0))); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2591,6 +2645,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, waybills.get(0))); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2612,10 +2667,18 @@ public class ReceivablePayableDetailServiceImpl } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason) { - saveChangeRecord(detail, content, reason, "0001"); + saveChangeRecord(detail, content, reason, "0001", LocalDateTime.now()); } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo) { + saveChangeRecord(detail, content, reason, lineNo, LocalDateTime.now()); + } + + private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo, + LocalDateTime adjustTime) { + if (!hasMeaningfulChange(content)) { + return; + } ReceivablePayableChangeRecord record = new ReceivablePayableChangeRecord(); record.setDetailId(detail.getId()); record.setLineNo(lineNo); @@ -2624,7 +2687,7 @@ public class ReceivablePayableDetailServiceImpl record.setAdjustUser(AuthUtil.getUserId()); record.setAdjustUserName(AuthUtil.getUserName()); record.setAdjustReason(reason); - record.setAdjustTime(LocalDateTime.now()); + record.setAdjustTime(adjustTime == null ? LocalDateTime.now() : adjustTime); changeRecordMapper.insert(record); } @@ -2889,6 +2952,43 @@ public class ReceivablePayableDetailServiceImpl return map; } + /** + * 结算币种优先取合同 settlementCurrency,其次运单 freightJson.currency,默认 RMB。 + */ + private String resolveCurrency(ContractManage contract, Waybill waybill) { + if (contract != null && Func.isNotEmpty(contract.getSettlementCurrency())) { + return normalizeCurrencyCode(contract.getSettlementCurrency()); + } + String freightCurrency = extractFreightCurrency(waybill); + if (Func.isNotEmpty(freightCurrency)) { + return normalizeCurrencyCode(freightCurrency); + } + return "RMB"; + } + + private String extractFreightCurrency(Waybill waybill) { + if (waybill == null || Func.isEmpty(waybill.getFreightJson())) { + return null; + } + Object currency = parseMap(waybill.getFreightJson()).get("currency"); + return currency == null ? null : String.valueOf(currency); + } + + private String normalizeCurrencyCode(String currency) { + if (Func.isEmpty(currency)) { + return "RMB"; + } + String code = currency.trim().toUpperCase(Locale.ROOT); + int separatorIndex = code.indexOf(" - "); + if (separatorIndex > 0) { + code = code.substring(0, separatorIndex).trim(); + } + if ("CNY".equals(code)) { + return "RMB"; + } + return code; + } + private Map parseMap(String json) { if (Func.isEmpty(json)) { return new LinkedHashMap<>(); @@ -2940,6 +3040,11 @@ public class ReceivablePayableDetailServiceImpl } private String formatQuantity(BigDecimal quantity, String unit) { + // 无有效运输量(空/0/-1)时不展示数值与单位 + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) == 0 + || quantity.compareTo(BigDecimal.valueOf(-1)) == 0) { + return "-"; + } return money(quantity).stripTrailingZeros().toPlainString() + (unit == null ? "" : " " + unit); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportCertRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportCertRecordServiceImpl.java new file mode 100644 index 00000000..b64a8a15 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportCertRecordServiceImpl.java @@ -0,0 +1,187 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.wrapper.TransportCertRecordWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 认证记录 服务实现类 + * + * @author Chill + */ +@Service +public class TransportCertRecordServiceImpl extends BaseServiceImpl implements ITransportCertRecordService { + + /** + * 合法的审核状态取值 + */ + private static final Set VALID_STATUS = Set.of( + TransportCertRecord.STATUS_PENDING, + TransportCertRecord.STATUS_APPROVED, + TransportCertRecord.STATUS_REJECTED, + TransportCertRecord.STATUS_SUPERSEDED + ); + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean save(TransportCertRecord transportCertRecord) { + validateRecord(transportCertRecord); + return super.save(transportCertRecord); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean saveBatch(Collection entityList, int batchSize) { + entityList.forEach(this::validateRecord); + return super.saveBatch(entityList, batchSize); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean saveOrUpdateBatch(Collection entityList, int batchSize) { + entityList.forEach(this::validateRecord); + return super.saveOrUpdateBatch(entityList, batchSize); + } + + /** + * 禁用继承自 {@code BaseService} 的「修改状态」 + *

+ * ⚠️ 本表的 {@code status} 是审核状态,不是「启用 / 停用」。 + * 而 {@code BaseServiceImpl#changeStatus} 会绕过 {@link #validateRecord} 直接写 status, + * 调用 {@code changeStatus(ids, 1)} 即等于批量通过审核—— + * 审核人、审核时间、驳回原因全部缺失,既丢失审核留痕,也会凭空造出生效快照。 + * 审核必须逐条走带留痕的审核方法。 + * + * @param ids 主键集合 + * @param status 状态 + * @return 永不返回,直接抛出 + */ + @Override + public boolean changeStatus(List ids, Integer status) { + throw new ServiceException("认证记录不支持直接修改状态,请使用审核接口"); + } + + @Override + public List selectRecordList(String bizType, Long bizId) { + return list(recordQuery(bizType, bizId)).stream() + .map(TransportCertRecordWrapper.build()::entityVO) + .collect(Collectors.toList()); + } + + @Override + public TransportCertRecord selectPendingRecord(String bizType, Long bizId) { + validateBizTarget(bizType, bizId); + return getOne(Wrappers.lambdaQuery() + .eq(TransportCertRecord::getBizType, bizType) + .eq(TransportCertRecord::getBizId, bizId) + .eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING) + .last("LIMIT 1")); + } + + /** + * 认证对象检索条件:按提交时间倒序 + *

+ * 排序用 {@code submit_time} 而非框架默认的 {@code create_time},是刻意为之: + * 认证记录的业务时间轴是提交时间,列表需按提交时间倒序展示每次提交。 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + * @return 检索条件 + */ + private LambdaQueryWrapper recordQuery(String bizType, Long bizId) { + validateBizTarget(bizType, bizId); + return Wrappers.lambdaQuery() + .eq(TransportCertRecord::getBizType, bizType) + .eq(TransportCertRecord::getBizId, bizId) + .orderByDesc(TransportCertRecord::getSubmitTime); + } + + /** + * 认证对象标识校验 + * + * @param bizType 认证对象类型 + * @param bizId 认证对象主键 + */ + private void validateBizTarget(String bizType, Long bizId) { + if (Func.isEmpty(bizType) || Func.isEmpty(bizId)) { + throw new ServiceException("认证对象类型与主键不能为空"); + } + } + + /** + * 记录落库前的强制校验 + *

+ * ⚠️ 这里防的是框架的一个静默行为:{@code BaseServiceImpl#resolveEntity} 在插入时 + * 若 {@code status} 为 {@code null},会填充 {@code BladeConstant.DB_STATUS_NORMAL}(值 1), + * 而 1 在本表恰好是 {@link TransportCertRecord#STATUS_APPROVED}。 + * 那会让一条「忘了设状态」的提交凭空变成「已通过」,进而可能被当成生效快照。 + * 故此处对空状态与非法取值直接拒绝,不依赖调用方自觉。 + * + * @param transportCertRecord 待落库的认证记录 + */ + private void validateRecord(TransportCertRecord transportCertRecord) { + if (Func.isEmpty(transportCertRecord)) { + throw new ServiceException("认证记录不能为空"); + } + if (Func.isEmpty(transportCertRecord.getBizType())) { + throw new ServiceException("认证对象类型不能为空"); + } + if (Func.isEmpty(transportCertRecord.getBizId())) { + throw new ServiceException("认证对象主键不能为空"); + } + if (Func.isEmpty(transportCertRecord.getSnapshotJson())) { + throw new ServiceException("认证内容快照不能为空"); + } + Integer status = transportCertRecord.getStatus(); + if (Func.isEmpty(status)) { + throw new ServiceException("认证记录状态不能为空,不允许依赖框架默认值(默认值 1 会被误判为已通过)"); + } + if (!VALID_STATUS.contains(status)) { + throw new ServiceException("认证记录状态取值不正确"); + } + if (TransportCertRecord.STATUS_REJECTED.equals(status) && Func.isEmpty(transportCertRecord.getRejectReason())) { + throw new ServiceException("请输入认证驳回原因"); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index fdad3a66..e28d7329 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java @@ -42,6 +42,7 @@ import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO; import org.springblade.transport.pojo.vo.TransportVehicleVO; import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,7 +52,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -63,7 +63,6 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class TransportVehicleServiceImpl extends BaseServiceImpl implements ITransportVehicleService { - private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$"); private static final String PLATE_COLOR_BLUE = "蓝牌"; private static final String PLATE_COLOR_YELLOW = "黄牌"; private static final String PLATE_COLOR_GREEN = "绿牌"; @@ -226,6 +225,11 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl minioClientProvider; private final String minioBucketName; private final String minioRootDirectory; + private final String minioTransformEndpoint; + private final String minioAccessKey; + private final String minioSecretKey; + private volatile MinioClient transformPresignClient; public VoucherManageServiceImpl(VoucherWaybillBatchMapper voucherWaybillBatchMapper, IProjectApplyService projectApplyService, IWaybillService waybillService, @@ -95,7 +99,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl minioClientProvider, @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName, - @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) { + @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory, + @Value("${file.storage.minio.transform-endpoint:${minio.transform-endpoint:}}") String minioTransformEndpoint, + @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String minioAccessKey, + @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String minioSecretKey) { this.voucherWaybillBatchMapper = voucherWaybillBatchMapper; this.projectApplyService = projectApplyService; this.waybillService = waybillService; @@ -106,6 +113,9 @@ public class VoucherManageServiceImpl extends BaseServiceImpl 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (Func.isNotEmpty(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (Func.isEmpty(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String normalizePlateNo(String plateNo) { return plateNo == null ? null : plateNo.replaceAll("[\\s-]", "").toUpperCase(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index b1eb6ab9..5a40decd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -342,9 +342,19 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl entityPage = page(page, queryWrapper); List records = entityPage.getRecords().stream().map(this::toVO).toList(); Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); @@ -352,6 +362,24 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl queryWrapper, Long carrierId) { + Long normalizedCarrierId = normalizeOptionalId(carrierId); + if (normalizedCarrierId == null) { + return; + } + CustomerArchive carrier = customerArchiveService.getById(normalizedCarrierId); + String carrierName = carrier == null ? null : Func.toStr(carrier.getFullName()).trim(); + queryWrapper.and(wrapper -> { + wrapper.apply("FIND_IN_SET({0}, IFNULL(carrier_ids,''))", String.valueOf(normalizedCarrierId)); + if (Func.isNotEmpty(carrierName)) { + wrapper.or().eq(WaybillImportBatch::getCarrierName, carrierName); + } + }); + } + @Override @Transactional(rollbackFor = Exception.class) public BusinessRemoveResultVO removeBatches(String ids) { @@ -396,8 +424,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl carrierIds = resolveCarrierIds(request); + batch.setCarrierIds(joinIds(carrierIds)); + batch.setCarrierName(resolveCarrierNames(carrierIds, request.getCarrierName())); batch.setImportStatus(importStatus); batch.setImportType(IMPORT_TYPE_SETTLEMENT.equals(request.getImportType()) ? IMPORT_TYPE_SETTLEMENT : IMPORT_TYPE_WAYBILL); ProjectApply project = projectApplyService.getById(request.getProjectId()); @@ -455,12 +484,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, String field, String fieldName, String... aliases) { String value = stringValue(row, field, aliases); - if (Func.isEmpty(value)) return null; + if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) { + return null; + } try { return new BigDecimal(value); } catch (NumberFormatException exception) { @@ -524,8 +555,32 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl resolveCarrierIds(WaybillImportBatchRequest request) { + if (request == null) { + return List.of(); + } + List carrierIds = Func.isEmpty(request.getCarrierIds()) + ? new ArrayList<>() + : request.getCarrierIds().stream() + .map(this::normalizeOptionalId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + if (Func.isNotEmpty(carrierIds)) { + return carrierIds; + } + Long carrierId = normalizeOptionalId(request.getCarrierId()); + if (carrierId != null) { + return List.of(carrierId); + } + String carrierName = Func.toStr(request.getCarrierName()).trim(); + if (Func.isEmpty(carrierName)) { + return List.of(); + } + CustomerArchive carrier = customerArchiveService.getOne(Wrappers.lambdaQuery() + .eq(CustomerArchive::getFullName, carrierName) + .eq(CustomerArchive::getIsDeleted, 0) + .last("limit 1")); + return carrier == null || carrier.getId() == null ? List.of() : List.of(carrier.getId()); + } + private String resolveCarrierNames(List carrierIds, String carrierName) { if (Func.isNotEmpty(carrierName)) return carrierName; if (Func.isEmpty(carrierIds)) return null; @@ -809,7 +896,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, String field, String fieldName, List errors) { String value = stringValue(row, field, fieldName); - if (Func.isEmpty(value)) { + if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) { return; } @@ -836,7 +923,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List errors) { String freightTotalStr = stringValue(row, "freightTotal", "运费合计"); - if (Func.isEmpty(freightTotalStr)) { + if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) { return; } @@ -845,8 +932,10 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl private static final String STATUS_DRAFT = "draft"; + /** 打卡记录类型:在途打卡(其余一律按过程节点打卡处理) */ + private static final String ENROUTE_PUNCH_TYPE = "enroute"; + @jakarta.annotation.Resource private ILoadingManageService loadingManageService; @@ -452,6 +458,29 @@ public class WaybillServiceImpl extends BaseServiceImpl continue; } + // 接单节点不落打卡流水:它是否完成由运单的接单事实决定,不看打卡表。 + // 司机确认接单即为已打卡;项目「无需确认接单」时,运单进入进行中即视为该环节已完成。 + // 后者没有接单动作,也就没有完成时刻,时间栏留空——运单上没有任何字段记着那一刻。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + boolean accepted = WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus()); + boolean noConfirm = !WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson); + if (accepted) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(formatPunchTime(waybill.getDriverAcceptTime())); + } else if (noConfirm && passedAcceptStage(waybill.getBusinessStatus())) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(""); + } else { + item.setPunched(false); + item.setStatusName("未打卡"); + item.setPunchTime(""); + } + records.add(item); + continue; + } + WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName); if (punched != null) { List types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName); @@ -483,6 +512,300 @@ public class WaybillServiceImpl extends BaseServiceImpl return vo; } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean savePunchRecord(WaybillPunchRecordSaveRequest request) { + if (request == null || request.getWaybillId() == null) { + throw new ServiceException("运单ID不能为空"); + } + Waybill waybill = getById(request.getWaybillId()); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + throw new ServiceException("运单不存在"); + } + // 只有显式传 false 才是取消打卡,未传按已打卡处理 + boolean punched = !Boolean.FALSE.equals(request.getPunched()); + if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) { + return saveEnroutePunchRecord(waybill, request, punched); + } + return saveNodePunchRecord(waybill, request, punched); + } + + /** + * 过程节点打卡:已打卡补录/修正,未打卡清除该节点流水。 + */ + private boolean saveNodePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + Long waybillId = waybill.getId(); + String processJson = resolveLiveProcessJson(waybill); + Map nodeConfig = findEnabledNodeConfig(processJson, request.getNodeCode(), request.getNodeName()); + // 节点以过程配置为准:配置改过 key/name 后,前端带上来的可能是旧值 + String nodeCode = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeCode()) + : WaybillProcessSupport.nodeKey(nodeConfig); + String nodeName = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeName()) + : WaybillProcessSupport.nodeName(nodeConfig); + if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) { + throw new ServiceException("过程节点不能为空"); + } + // 接单节点没有打卡流水:它是否完成由运单的接单事实决定,补录与取消都改不动它。 + // 放行只会造出一条与运单事实打架的流水——时间轴显示已打卡,运单却还没人接单。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + throw new ServiceException("接单节点由接单动作决定,不支持补录或取消打卡"); + } + List rows = nodePunchRows(waybillId, nodeCode, nodeName); + if (!punched) { + if (!rows.isEmpty()) { + waybillNodePunchMapper.deleteBatchIds(rows.stream().map(WaybillNodePunch::getId).toList()); + } + return true; + } + WaybillNodePunch punch = pickLatestNodePunch(rows, nodeCode, nodeName); + boolean isNew = punch == null; + if (isNew) { + punch = new WaybillNodePunch(); + punch.setWaybillId(waybillId); + punch.setWaybillNo(waybill.getWaybillNo()); + // 管理端补录没有打卡司机,driver_id 留空,不记到任何司机头上 + punch.setExceptionFlag(0); + } + if (Func.isNotEmpty(nodeCode)) { + punch.setNodeCode(nodeCode); + } + if (Func.isNotEmpty(nodeName)) { + punch.setNodeName(nodeName); + } + punch.setPunchTime(parsePunchTime(request.getPunchTime())); + punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress())); + punch.setLongitude(parseCoordinate(request.getLongitude(), "经度")); + punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度")); + punch.setWeight(TransportBusinessSupport.trimToNull(request.getWeight())); + punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume())); + punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity())); + punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark())); + List> photoItems = punchPhotoItems(request.getPhotos()); + assertPunchPhotoTypeLimit(photoItems); + punch.setPhotos(photoItems.isEmpty() ? null : JsonUtil.toJson(photoItems)); + // 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护 + if (isNew) { + waybillNodePunchMapper.insert(punch); + } else { + updateNodePunchFields(punch); + } + return true; + } + + /** + * 显式按字段更新节点打卡。 + *

+ * 不能用 updateById:它按「非空才更新」生成 SQL,把地址/货量/照片清空时这些字段为 null, + * 会被整条跳过——界面上删掉的内容保存后还在。 + */ + private void updateNodePunchFields(WaybillNodePunch punch) { + waybillNodePunchMapper.update(null, Wrappers.lambdaUpdate() + .eq(WaybillNodePunch::getId, punch.getId()) + .set(WaybillNodePunch::getNodeCode, punch.getNodeCode()) + .set(WaybillNodePunch::getNodeName, punch.getNodeName()) + .set(WaybillNodePunch::getPunchTime, punch.getPunchTime()) + .set(WaybillNodePunch::getAddress, punch.getAddress()) + .set(WaybillNodePunch::getLongitude, punch.getLongitude()) + .set(WaybillNodePunch::getLatitude, punch.getLatitude()) + .set(WaybillNodePunch::getWeight, punch.getWeight()) + .set(WaybillNodePunch::getVolume, punch.getVolume()) + .set(WaybillNodePunch::getQuantity, punch.getQuantity()) + .set(WaybillNodePunch::getRemark, punch.getRemark()) + .set(WaybillNodePunch::getPhotos, punch.getPhotos()) + // entity 传 null,MP 不会自动填充审计字段,这里补上 + .set(WaybillNodePunch::getUpdateTime, new Date()) + .set(WaybillNodePunch::getUpdateUser, AuthUtil.getUserId())); + } + + /** + * 在途打卡:时间轴上的在途节点只展示最新一条流水。 + */ + private boolean saveEnroutePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + Long waybillId = waybill.getId(); + List rows = waybillEnroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybillId) + .orderByAsc(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getId)); + if (!punched) { + // 在途节点没有节点 key 可定位单条,取消打卡即清空该运单全部在途流水, + // 否则更早的流水会顶上来继续显示已打卡 + if (!rows.isEmpty()) { + waybillEnroutePunchMapper.deleteBatchIds(rows.stream().map(WaybillEnroutePunch::getId).toList()); + } + return true; + } + WaybillEnroutePunch punch = rows.isEmpty() ? null : rows.get(rows.size() - 1); + boolean isNew = punch == null; + if (isNew) { + punch = new WaybillEnroutePunch(); + punch.setWaybillId(waybillId); + punch.setWaybillNo(waybill.getWaybillNo()); + } + punch.setPunchTime(parsePunchTime(request.getPunchTime())); + punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress())); + punch.setLongitude(parseCoordinate(request.getLongitude(), "经度")); + punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度")); + // 在途打卡表只有一个照片字段(单张),多张只保留第一张 + punch.setPhoto(firstPunchPhotoUrl(request.getPhotos())); + if (isNew) { + waybillEnroutePunchMapper.insert(punch); + } else { + // 同节点打卡:显式 set,否则清空地址/照片保存后不生效 + waybillEnroutePunchMapper.update(null, Wrappers.lambdaUpdate() + .eq(WaybillEnroutePunch::getId, punch.getId()) + .set(WaybillEnroutePunch::getPunchTime, punch.getPunchTime()) + .set(WaybillEnroutePunch::getAddress, punch.getAddress()) + .set(WaybillEnroutePunch::getLongitude, punch.getLongitude()) + .set(WaybillEnroutePunch::getLatitude, punch.getLatitude()) + .set(WaybillEnroutePunch::getPhoto, punch.getPhoto()) + .set(WaybillEnroutePunch::getUpdateTime, new Date()) + .set(WaybillEnroutePunch::getUpdateUser, AuthUtil.getUserId())); + } + return true; + } + + /** + * 该节点名下的全部打卡流水(含历史),按打卡时间升序。 + *

+ * 取消打卡要清空全部,只软删最新一条会让更早的历史流水顶上来继续显示已打卡。 + */ + private List nodePunchRows(Long waybillId, String nodeCode, String nodeName) { + return waybillNodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId) + .orderByAsc(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getId)) + .stream() + .filter(punch -> { + String rowCode = TransportBusinessSupport.trimToEmpty(punch.getNodeCode()); + String rowName = TransportBusinessSupport.trimToEmpty(punch.getNodeName()); + if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(rowCode)) { + return true; + } + return Func.isNotEmpty(nodeName) && nodeName.equals(rowName); + }) + .toList(); + } + + /** + * 取该节点最新一条流水,口径与展示层一致:nodeCode 命中优先,未命中才回退 nodeName。 + */ + private WaybillNodePunch pickLatestNodePunch(List rows, String nodeCode, String nodeName) { + WaybillNodePunch byCode = null; + WaybillNodePunch byName = null; + for (WaybillNodePunch row : rows) { + if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(row.getNodeCode()))) { + byCode = row; + } else if (Func.isNotEmpty(nodeName) && nodeName.equals(TransportBusinessSupport.trimToEmpty(row.getNodeName()))) { + byName = row; + } + } + return byCode != null ? byCode : byName; + } + + /** + * 过程配置中启用(未启用时不参与打卡,补录无从校验)的节点配置;找不到返回 null。 + */ + private Map findEnabledNodeConfig(String processJson, String nodeCode, String nodeName) { + String code = TransportBusinessSupport.trimToEmpty(nodeCode); + String name = TransportBusinessSupport.trimToEmpty(nodeName); + List> nodes = WaybillProcessSupport.listEnabledProcessNodes(processJson); + if (nodes.isEmpty()) { + nodes = WaybillProcessSupport.listDriverPunchNodes(processJson); + } + for (Map node : nodes) { + if (Func.isNotEmpty(code) && code.equalsIgnoreCase(WaybillProcessSupport.nodeKey(node))) { + return node; + } + if (Func.isNotEmpty(name) && name.equals(WaybillProcessSupport.nodeName(node))) { + return node; + } + } + return null; + } + + /** + * 打卡照片规范成 [{type,url}]:保留原结构,URL 为空的一律丢弃,类型为空按序号补「凭证N」。 + */ + private List> punchPhotoItems(List photos) { + List> items = new ArrayList<>(); + if (photos == null || photos.isEmpty()) { + return items; + } + for (WaybillPunchRecordSaveRequest.Photo photo : photos) { + String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl()); + if (Func.isEmpty(url)) { + continue; + } + String type = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getType()); + Map item = new LinkedHashMap<>(); + item.put("type", Func.isEmpty(type) ? ("凭证" + (items.size() + 1)) : type); + item.put("url", url); + items.add(item); + } + return items; + } + + /** + * 断言打卡照片每种类型都不超过单类上限。 + *

+ * 上限按类型各自计数,不是节点合计:一个节点可以同时有「打卡照片」与多类凭证,各自最多 9 张。 + * 订正不校验必填——订正的是已经发生的事实,不是重新走一遍流程;必填只约束司机提交。 + */ + private void assertPunchPhotoTypeLimit(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + for (Map.Entry entry : countByType.entrySet()) { + if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) { + throw new ServiceException("「" + entry.getKey() + "」最多上传 " + + WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片"); + } + } + } + + private String firstPunchPhotoUrl(List photos) { + if (photos == null) { + return null; + } + for (WaybillPunchRecordSaveRequest.Photo photo : photos) { + String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl()); + if (Func.isNotEmpty(url)) { + return url; + } + } + return null; + } + + /** + * 打卡时间为空表示「此刻补录」;格式非法直接报错,不静默改成当前时间。 + */ + private Date parsePunchTime(String text) { + String value = TransportBusinessSupport.trimToEmpty(text); + if (Func.isEmpty(value)) { + return new Date(); + } + try { + return DateUtil.parse(value, DateUtil.PATTERN_DATETIME); + } catch (Exception exception) { + throw new ServiceException("打卡时间格式不正确,应为 yyyy-MM-dd HH:mm:ss"); + } + } + + private BigDecimal parseCoordinate(String text, String label) { + String value = TransportBusinessSupport.trimToEmpty(text); + if (Func.isEmpty(value)) { + return null; + } + try { + return new BigDecimal(value); + } catch (NumberFormatException exception) { + throw new ServiceException(label + "格式不正确"); + } + } + private WaybillNodePunch findLatestNodePunch(Map index, String nodeCode, String nodeName) { if (index == null || index.isEmpty()) { return null; @@ -636,6 +959,18 @@ public class WaybillServiceImpl extends BaseServiceImpl return photo; } + /** + * 运单是否已越过接单阶段。 + *

+ * 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、 + * 调度中这些并未越过接单的阶段,取反会把它们判成已接单。 + */ + private static boolean passedAcceptStage(String businessStatus) { + return "running".equals(businessStatus) + || "completed".equals(businessStatus) + || "cancelled".equals(businessStatus); + } + private String formatPunchTime(Date time) { if (time == null) { return ""; @@ -727,10 +1062,13 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setMasterNo(null); waybill.setMileageRemark(null); } - // 新建若已带业务状态(如复制提交)则在过程校正后回写,保证与原运单一致 + // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致 String preservedBusinessStatus = created ? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()) : null; + if (isCompletedBusinessStatus(preservedBusinessStatus)) { + preservedBusinessStatus = "completed"; + } // 无过程快照时按项目回填;再按接单设置决定 pending / running fillProjectProcessConfig(waybill); prepare(waybill); @@ -1006,36 +1344,48 @@ public class WaybillServiceImpl extends BaseServiceImpl target.setFreightJson(source.getFreightJson()); target.setAttachmentsJson(source.getAttachmentsJson()); target.setRemark(source.getRemark()); - // 复制后业务状态与原运单保持一致 - String sourceBusinessStatus = source.getBusinessStatus(); - target.setBusinessStatus(sourceBusinessStatus); + // 复制新建固定为草稿,不沿用原运单业务状态 + target.setBusinessStatus(STATUS_DRAFT); target.setWaybillNo(nextCode()); clearDriverAcceptRecord(target); fillProjectProcessConfig(target); prepare(target); - applyDriverAcceptBusinessStatus(target); - if (Func.isNotEmpty(sourceBusinessStatus)) { - target.setBusinessStatus(sourceBusinessStatus); - } + target.setBusinessStatus(STATUS_DRAFT); validate(target); save(target); - // 原运单已完成时,复制后按合同系统计费规则生成应收应付明细 - generateReceivablePayableIfCompleted(target); return detail(target.getId()); } /** - * 新建/复制运单若业务状态为已完成,按合同系统计费模式生成应收、应付明细。 - *

使用同事务入口,避免 REQUIRES_NEW 读不到尚未提交的新运单。

+ * 新建/复制运单若业务状态为已完成,按与「完成运单」相同的合同系统计费规则生成应收、应付明细。 + *

使用同事务入口 {@link IReceivablePayableDetailService#generateForImportedWaybills}, + * 避免 {@code generateForCompletedWaybills} 的 REQUIRES_NEW 读不到尚未提交的新运单。

*/ private void generateReceivablePayableIfCompleted(Waybill waybill) { if (waybill == null || Func.isEmpty(waybill.getId())) { return; } - if (!"completed".equals(waybill.getBusinessStatus())) { + Waybill saved = getById(waybill.getId()); + if (saved == null || Objects.equals(saved.getIsDeleted(), 1)) { return; } - receivablePayableDetailService.generateForImportedWaybills(List.of(waybill)); + String businessStatus = TransportBusinessSupport.trimToNull(saved.getBusinessStatus()); + if (Func.isEmpty(businessStatus)) { + businessStatus = TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()); + } + if (!isCompletedBusinessStatus(businessStatus)) { + return; + } + // 统一落库英文状态码,与完成运单、列表展示口径一致 + if (!"completed".equals(businessStatus)) { + saved.setBusinessStatus("completed"); + updateById(saved); + } + receivablePayableDetailService.generateForImportedWaybills(List.of(saved)); + } + + private static boolean isCompletedBusinessStatus(String businessStatus) { + return "completed".equals(businessStatus) || "已完成".equals(businessStatus); } @Override @@ -1458,7 +1808,9 @@ public class WaybillServiceImpl extends BaseServiceImpl } /** - * 完整录入多货物时,运单主表 quantity 按 goodsJson 数量合计回写,避免只落首行数量。 + * 从 goodsJson 回写运单主表货物字段: + * 1. 顶层数量单位 / 货物名称 / 货物类型为空时,取首条有效货物补齐; + * 2. 完整录入多货物时,数量按 goodsJson 合计回写。 */ private void syncQuantityFromGoodsJson(Waybill waybill) { if (waybill == null || Func.isEmpty(waybill.getGoodsJson())) { @@ -1470,7 +1822,53 @@ public class WaybillServiceImpl extends BaseServiceImpl } catch (Exception exception) { return; } - if (Func.isEmpty(goodsList) || goodsList.size() <= 1) { + if (Func.isEmpty(goodsList)) { + return; + } + Map firstGoods = null; + for (Map goods : goodsList) { + if (goods == null) { + continue; + } + firstGoods = goods; + break; + } + if (firstGoods != null) { + if (Func.isEmpty(waybill.getQuantityUnit())) { + String quantityUnit = firstNonBlank(firstGoods, "quantityUnit", "goodsQuantityUnit", "unit"); + if (Func.isNotEmpty(quantityUnit)) { + waybill.setQuantityUnit(quantityUnit); + } + } + if (Func.isEmpty(waybill.getCargoName())) { + String cargoName = firstNonBlank(firstGoods, "cargoName", "goodsName"); + if (Func.isNotEmpty(cargoName)) { + waybill.setCargoName(cargoName); + } + } + if (Func.isEmpty(waybill.getCargoType())) { + String cargoType = firstNonBlank(firstGoods, "cargoType", "goodsType"); + if (Func.isNotEmpty(cargoType)) { + waybill.setCargoType(cargoType); + } + } + if (waybill.getQuantity() == null && goodsList.size() == 1) { + Object quantityValue = firstGoods.get("quantity"); + if (quantityValue == null) { + quantityValue = firstGoods.get("cargoQuantity"); + } + if (quantityValue == null) { + quantityValue = firstGoods.get("goodsQuantity"); + } + if (quantityValue != null && Func.isNotEmpty(String.valueOf(quantityValue).trim())) { + BigDecimal quantity = new BigDecimal(String.valueOf(quantityValue).trim()); + if (!isSentinelMinusOne(quantity)) { + waybill.setQuantity(quantity); + } + } + } + } + if (goodsList.size() <= 1) { return; } BigDecimal total = BigDecimal.ZERO; @@ -1491,6 +1889,23 @@ public class WaybillServiceImpl extends BaseServiceImpl } } + private String firstNonBlank(Map goods, String... keys) { + if (goods == null || keys == null) { + return null; + } + for (String key : keys) { + Object value = goods.get(key); + if (value == null) { + continue; + } + String text = String.valueOf(value).trim(); + if (Func.isNotEmpty(text)) { + return text; + } + } + return null; + } + private void validate(Waybill waybill) { TransportBusinessSupport.validateRequired(waybill.getProjectName(), "项目不能为空"); TransportBusinessSupport.validateRequired(waybill.getContractName(), "客户合同不能为空"); @@ -1505,7 +1920,7 @@ public class WaybillServiceImpl extends BaseServiceImpl if (isRoadTransport(waybill.getTransportType())) { if ("承运商".equals(waybill.getCarrierType())) { TransportBusinessSupport.validateRequired(waybill.getCarrierName(), "承运商不能为空"); - } else { + } else if (!isBatchImport(waybill)) { TransportBusinessSupport.validateRequired(waybill.getDriverName(), "司机不能为空"); } } @@ -1658,6 +2073,10 @@ public class WaybillServiceImpl extends BaseServiceImpl return value.contains("公路") || value.contains("道路") || value.contains("road") || "gl".equals(value); } + private boolean isBatchImport(Waybill waybill) { + return waybill != null && TransportBusinessSupport.DATA_SOURCE_BATCH_IMPORT.equals(waybill.getDataSource()); + } + private void fillJsonFromFlatFields(Waybill waybill) { if (Func.isEmpty(waybill.getTaskEntryMode())) { waybill.setTaskEntryMode("simple"); @@ -1811,9 +2230,20 @@ public class WaybillServiceImpl extends BaseServiceImpl } private void putIfNotEmpty(Map map, String key, Object value) { - if (Func.isNotEmpty(value)) { - map.put(key, value); + if (Func.isEmpty(value) || isSentinelMinusOneValue(value)) { + return; } + map.put(key, value); + } + + private boolean isSentinelMinusOneValue(Object value) { + if (value instanceof BigDecimal bigDecimal) { + return isSentinelMinusOne(bigDecimal); + } + if (value instanceof Number number) { + return Double.compare(number.doubleValue(), -1D) == 0; + } + return false; } private synchronized String nextCode() { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java new file mode 100644 index 00000000..4817baaf --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java @@ -0,0 +1,62 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +/** + * 认证审核的权限表达式 + *

+ * 用菜单码而不是角色名。本项目的权限走「角色-菜单」授权,写死角色名有两个坑: + * 角色别名可能对不上(曾写成 {@code admin},而库里只有 {@code administrator}), + * 以及会漏掉实际承担审核的岗位(例如「运管」)。 + *

+ * 用表达式而不是 {@code @PreAuth(menu = "...")} 的单值形式,是因为同一组接口 + * 由 {@code bizType} 参数区分、同时服务司机与车辆两侧,单值 menu 表达不了「二者之一」。 + *

+ * 表达式最终由框架交给 SpEL 求值,root object 是 {@code AuthFun}, + * 故这里用的函数名必须与它的公开方法一致({@code hasMenu})。 + * {@code CertAuditPermissionsTest} 会实际解析并求值这两个表达式, + * 一旦写错函数名或括号,测试即失败——而不是等到线上请求时抛 500。 + * + * @author Chill + */ +public final class CertAuditPermissions { + + /** + * 待审列表与审核详情:司机或车辆任一审核菜单即可 + */ + public static final String AUDIT_VIEW = + "hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')"; + + /** + * 审核动作:司机或车辆任一「审核」按钮权限即可 + */ + public static final String AUDIT_ACTION = + "hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')"; + + private CertAuditPermissions() { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java new file mode 100644 index 00000000..5a4ed782 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java @@ -0,0 +1,127 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 认证可用性门槛 + *

+ * 在运营状态(启用 / 停用)之上叠加一层「是否已认证」: + * 车辆需运营状态为启用且存在生效快照;司机需存在生效快照。 + *

+ * 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存 + * 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。 + *

+ * 两个开关默认开启,可关掉以回退到不校验认证的行为: + *

+ * transport:
+ *   certification:
+ *     vehicle-gate-enabled: false
+ *     driver-gate-enabled: false
+ * 
+ * + * @author Chill + */ +@Component +@Getter +@Setter +public class CertificationGate { + + /** + * 车辆认证门槛开关 + */ + @Value("${transport.certification.vehicle-gate-enabled:true}") + private boolean vehicleGateEnabled = true; + + /** + * 司机认证门槛开关 + */ + @Value("${transport.certification.driver-gate-enabled:true}") + private boolean driverGateEnabled = true; + + /** + * 判定车辆是否可用 + *

+ * 运营状态优先:停用的车即便已认证也不可用(认证门槛只叠加,不覆盖运营状态)。 + * + * @param enabledState 运营状态:TRUE 启用 + * @param effectiveRecordId 生效快照指针 + * @return 是否可用 + */ + public boolean isVehicleAvailable(Boolean enabledState, Long effectiveRecordId) { + return vehicleUnavailableReason(enabledState, effectiveRecordId) == null; + } + + /** + * 车辆不可用的原因 + *

+ * 停用与未认证是两件事,处置方式也不同:一个要找调度启用车辆,一个要去提交认证。 + * 合并成同一句话,会把「车已认证、只是被停用」误导成「认证没通过」, + * 让人白做一轮重新认证。 + * + * @param enabledState 运营状态:TRUE 启用 + * @param effectiveRecordId 生效快照指针 + * @return 不可用原因;可用时返回 null + */ + public String vehicleUnavailableReason(Boolean enabledState, Long effectiveRecordId) { + if (!Boolean.TRUE.equals(enabledState)) { + return "该车辆已停用,暂不可接单,请联系调度启用"; + } + if (!vehicleGateEnabled) { + return null; + } + return CertificationRuleSupport.isCertified(effectiveRecordId) + ? null + : "该车辆尚未通过认证审核,暂不可接单,请先提交认证"; + } + + /** + * 判定司机是否可接单 + * + * @param effectiveRecordId 生效快照指针 + * @return 是否可用 + */ + public boolean isDriverAvailable(Long effectiveRecordId) { + if (!driverGateEnabled) { + return true; + } + return CertificationRuleSupport.isCertified(effectiveRecordId); + } + + /** + * 司机不可用的提示文案 + * + * @return 面向司机的中文提示 + */ + public String driverUnavailableReason() { + return "您尚未通过认证审核,暂不可接单,请先完成认证"; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java new file mode 100644 index 00000000..7fc8ce28 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java @@ -0,0 +1,180 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.TransportCertRecord; + +/** + * 认证记录状态机规则 + *

+ * 把「什么状态能做什么」集中在一处,与持久化分离,便于单独验证。 + * 这几条规则是整个认证改造的地基,改坏的后果是线上事故: + * 司机重新提交认证后车辆掉出可用池,或同一辆车出现两条待审记录。 + * + * @author Chill + */ +public final class CertificationRuleSupport { + + private static final String STATUS_TEXT_PENDING = "审核中"; + private static final String STATUS_TEXT_APPROVED = "已通过"; + private static final String STATUS_TEXT_REJECTED = "已驳回"; + private static final String STATUS_TEXT_SUPERSEDED = "已作废"; + private static final String STATUS_TEXT_UNKNOWN = "未知状态"; + + private CertificationRuleSupport() { + } + + /** + * 把一条新提交的记录准备成待审 + *

+ * 必须清空驳回原因:新提交是全新一次认证,不能继承上一次的驳回文案, + * 否则前端会显示「审核中」却带着旧的驳回原因。 + * + * @param incoming 待入库的记录 + */ + public static void prepareForSubmit(TransportCertRecord incoming) { + incoming.setStatus(TransportCertRecord.STATUS_PENDING); + incoming.setRejectReason(null); + } + + /** + * 再次提交时,把该认证对象**前一条待审**记录转为已作废 + *

+ * 只动待审记录:已通过、已驳回、已作废都是终态,一律不碰。 + * 特别是**不得改动已通过的记录**——它可能正被生效快照指针指着。 + * + * @param previousPending 该对象当前那条待审记录,可为 null + * @return 需要更新为已作废的记录;无需更新时返回 null + */ + public static TransportCertRecord supersedeIfPending(TransportCertRecord previousPending) { + if (previousPending == null) { + return null; + } + if (!TransportCertRecord.STATUS_PENDING.equals(previousPending.getStatus())) { + return null; + } + previousPending.setStatus(TransportCertRecord.STATUS_SUPERSEDED); + return previousPending; + } + + /** + * 判定认证对象是否已认证 + *

+ * 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」, + * 重新提交、待审、被驳回都不会让对象变回未认证。 + * + * @param effectiveRecordId 生效快照指针 + * @return 是否已认证 + */ + public static boolean isCertified(Long effectiveRecordId) { + return Func.isNotEmpty(effectiveRecordId); + } + + /** + * 判定一条记录是否可被审核 + *

+ * 只有待审记录可审。已通过/已驳回/已作废都是终态,再次审核会把生效快照改坏 + * (例如把指针指着的那条「已通过」改成「已驳回」,生效值就没了)。 + * + * @param record 认证记录 + * @return 是否可审核 + */ + public static boolean isAuditable(TransportCertRecord record) { + return record != null && TransportCertRecord.STATUS_PENDING.equals(record.getStatus()); + } + + /** + * 校验审核入参是否合法 + * + * @param pass 是否通过 + * @param rejectReason 驳回原因 + * @return 是否合法 + */ + public static boolean isRejectReasonValid(boolean pass, String rejectReason) { + if (pass) { + return true; + } + String reason = Func.toStr(rejectReason, "").trim(); + return Func.isNotEmpty(reason) + && reason.length() <= TransportCertRecord.REJECT_REASON_MAX_LENGTH; + } + + /** + * 审核通过时的驳回原因:必须为 null,避免残留上一次的驳回文案 + * + * @return 恒为 null + */ + public static String reasonForApprove() { + return null; + } + + /** + * 审核驳回时的驳回原因:去首尾空白后落库 + * + * @param rejectReason 原始原因 + * @return 去空白后的原因 + */ + public static String reasonForReject(String rejectReason) { + return Func.toStr(rejectReason, "").trim(); + } + + /** + * 状态对应的中文文案 + *

+ * 四端(小程序、Web、接口、库注释)必须用同一套说法,不要各造一套。 + * + * @param status 状态值 + * @return 中文文案 + */ + public static String statusText(Integer status) { + if (TransportCertRecord.STATUS_PENDING.equals(status)) { + return STATUS_TEXT_PENDING; + } + if (TransportCertRecord.STATUS_APPROVED.equals(status)) { + return STATUS_TEXT_APPROVED; + } + if (TransportCertRecord.STATUS_REJECTED.equals(status)) { + return STATUS_TEXT_REJECTED; + } + if (TransportCertRecord.STATUS_SUPERSEDED.equals(status)) { + return STATUS_TEXT_SUPERSEDED; + } + return STATUS_TEXT_UNKNOWN; + } + + /** + * 校验认证对象类型 + * + * @param bizType 类型 + * @return 是否为合法类型 + */ + public static boolean isValidBizType(String bizType) { + return TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType) + || TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java new file mode 100644 index 00000000..730f7453 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java @@ -0,0 +1,47 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +/** + * 异常处置的权限表达式 + *

+ * 用菜单码而不是角色名:本项目的权限走「角色-菜单」授权。 + *

+ * 表达式由框架交给 SpEL 求值,root object 是 {@code AuthFun}, + * 故函数名必须与它的公开方法一致({@code hasMenu})。 + * 写成 {@code @PreAuth("exception_disposal_edit")} 这样的裸字符串会被当作 + * 属性访问而非权限判断,请求时抛 500 而不是 403。 + * + * @author Chill + */ +public final class ExceptionDisposalPermissions { + + /** + * 编辑异常处置的上报内容 + */ + public static final String EDIT = "hasMenu('exception_disposal_edit')"; + + private ExceptionDisposalPermissions() { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java new file mode 100644 index 00000000..ef4e7798 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java @@ -0,0 +1,403 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 把百度 OCR 的原始结果归一化成业务字段 + *

+ * 百度返回的 {@code words_result} 键是**中文**(「号牌号码」「车辆识别代码」…), + * 同一含义在纸质证与电子证照之间还有不同写法。让小程序去认识这些中文键, + * 等于把百度接口的形状泄漏到前端,接口一改就要动多个端。 + *

+ * 归一化后下发的键与业务字段同名({@code plateNo}、{@code ownerName}…),前端拿到即可回填; + * 原始结果仍一并下发,归一化没覆盖到的字段不至于丢失。 + *

+ * 必须做别名容错的原因:官方文档列举的是「号牌号码」「车辆识别代码」, + * 而实际返回里也出现过「车牌号码」「车辆识别代号」这类写法,电子行驶证还多出 + * 「强制报废期止」「能源种类」等字段。写死单个键名的后果是**识别成功但什么也没填**—— + * 不报错、不抛异常,最难排查。 + * + * @author Chill + */ +public final class OcrFieldSupport { + + /** + * {@code words_result} 里每个条目形如 {@code {"words": "桂A12345"}} + */ + private static final String WORDS = "words"; + + /** + * 表示「长期有效」的词:命中后不产出日期,而把对应的长期有效标志置 1 + */ + private static final String LONG_TERM_TEXT = "长期"; + + /** + * 日期写法:4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(年月日 / 点 / 横线) + */ + private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})"); + + private OcrFieldSupport() { + } + + /** + * 按证件类型把原始结果归一化 + * + * @param type 证件类型 + * @param side 证件面:front / back(目前两个面共用一套字段别名) + * @param result 百度原始返回 + * @return 归一化字段(键与业务字段同名);识别不到任何字段时返回空 Map + */ + public static Map extract(BaiduOcrType type, String side, JsonNode result) { + Map words = readWords(result); + Map fields = new LinkedHashMap<>(); + if (words.isEmpty()) { + return fields; + } + for (Map.Entry> entry : aliasTable(type).entrySet()) { + String raw = firstMatch(words, entry.getValue()); + if (Func.isEmpty(raw)) { + continue; + } + putField(fields, entry.getKey(), raw); + } + // 外廓尺寸是唯一「一项要拆成三个字段」的,单独处理 + splitOuterSize(words, fields); + // 道路运输证的「有效期」常写成一个区间:止期已由上面取到最后一段, + // 起期要从同一段文本里补,否则用户得照着一张图再敲一遍 + splitRangeStart(words, fields, "有效期", "roadTransportCertStartDate"); + return fields; + } + + /** + * 从「有效期」这类区间文本里取出起期,补进对应的起期字段 + * + * @param words 识别结果 + * @param fields 结果集 + * @param alias 区间所在的键名 + * @param startKey 要补的起期字段名 + */ + private static void splitRangeStart(Map words, Map fields, + String alias, String startKey) { + if (fields.containsKey(startKey)) { + return; + } + String text = firstMatch(words, List.of(alias)); + if (Func.isEmpty(text)) { + return; + } + List dates = matchDates(text); + // 至少两段才算区间:只有一段说明那是止期本身,硬当起期会填错 + if (dates.size() >= 2) { + fields.put(startKey, dates.get(0)); + } + } + + /** + * 读取 {@code words_result} 成「中文键 → 文本值」 + * + * @param result 百度原始返回 + * @return 键值对;结构不符时返回空 Map + */ + private static Map readWords(JsonNode result) { + Map words = new LinkedHashMap<>(); + if (result == null) { + return words; + } + JsonNode wordsResult = result.path("words_result"); + if (!wordsResult.isObject()) { + return words; + } + wordsResult.fields().forEachRemaining(entry -> { + JsonNode value = entry.getValue(); + if (value == null || value.isNull()) { + return; + } + // 可能是 {"words": "..."},也可能直接是字符串 + String text = value.isObject() ? value.path(WORDS).asText("") : value.asText(""); + if (Func.isNotEmpty(text)) { + words.put(entry.getKey(), text.trim()); + } + }); + return words; + } + + /** + * 取第一个命中的别名值 + * + * @param words 识别结果 + * @param aliases 候选键名,按优先级排列 + * @return 命中值;都没命中返回 null + */ + private static String firstMatch(Map words, List aliases) { + for (String alias : aliases) { + String value = words.get(alias); + if (Func.isNotEmpty(value)) { + return value; + } + } + // 精确键名没命中时按「包含」兜底:百度偶有「核定载质量(kg)」这类带后缀的键 + for (String alias : aliases) { + for (Map.Entry entry : words.entrySet()) { + if (entry.getKey().contains(alias) && Func.isNotEmpty(entry.getValue())) { + return entry.getValue(); + } + } + } + return null; + } + + /** + * 写入一个字段,按字段语义做值归一化 + *

+ * 数字字段转数字、日期字段转 {@code yyyy-MM-dd};「长期」不产出日期, + * 而是把对应的长期有效标志置 1——否则「长期」会被当成一个非法日期塞进日期框。 + * + * @param fields 结果集 + * @param key 业务字段名 + * @param raw 识别到的原始文本 + */ + private static void putField(Map fields, String key, String raw) { + String text = raw.trim(); + if (isDateField(key)) { + if (text.contains(LONG_TERM_TEXT)) { + String longTermKey = longTermKeyOf(key); + if (longTermKey != null) { + fields.put(longTermKey, 1); + } + return; + } + String date = normalizeDate(text, key); + if (date != null) { + fields.put(key, date); + } + return; + } + if (isNumberField(key)) { + Integer number = normalizeNumber(text); + if (number != null) { + fields.put(key, number); + } + return; + } + fields.put(key, text); + } + + /** + * 「外廓尺寸」一项拆成外廓长/宽/高三个字段 + *

+ * 形如 {@code 5990×2300×3100},分隔符有 ×、x、*、空格等多种写法。 + * + * @param words 识别结果 + * @param fields 结果集 + */ + private static void splitOuterSize(Map words, Map fields) { + String size = firstMatch(words, List.of("外廓尺寸")); + if (Func.isEmpty(size)) { + return; + } + List parts = new ArrayList<>(); + for (String part : size.split("[^0-9]+")) { + if (Func.isNotEmpty(part)) { + parts.add(part); + } + } + // 必须正好三段才写回:只识别到两段时多半是漏识别, + // 按顺序硬填会把宽度填成长度、高度填成宽度 + if (parts.size() != 3) { + return; + } + String[] keys = {"outerLength", "outerWidth", "outerHeight"}; + for (int index = 0; index < keys.length; index++) { + Integer number = normalizeNumber(parts.get(index)); + if (number != null) { + fields.put(keys[index], number); + } + } + } + + /** + * 各证件类型的字段别名表 + * + * @param type 证件类型 + * @return 「业务字段名 → 百度键名候选」 + */ + private static Map> aliasTable(BaiduOcrType type) { + if (type == null) { + return Map.of(); + } + String name = type.name(); + if (BaiduOcrType.ID_CARD.name().equals(name)) { + return idCardAliases(); + } + if (BaiduOcrType.DRIVING_LICENSE.name().equals(name)) { + return drivingLicenseAliases(); + } + if (BaiduOcrType.VEHICLE_LICENSE.name().equals(name)) { + return vehicleLicenseAliases(); + } + if (BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE.name().equals(name)) { + return roadTransportAliases(); + } + return Map.of(); + } + + private static Map> idCardAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("realName", List.of("姓名")); + aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码")); + aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起")); + aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止")); + return aliases; + } + + private static Map> drivingLicenseAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("driverFileNo", List.of("档案编号", "证号")); + aliases.put("driverClass", List.of("准驾车型")); + aliases.put("driverValidStart", List.of("有效起始日期", "有效期起", "初次领证日期")); + aliases.put("driverValidEnd", List.of("有效期限", "有效期止", "有效期限止")); + return aliases; + } + + private static Map> vehicleLicenseAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("plateNo", List.of("号牌号码", "车牌号码", "号牌")); + aliases.put("vehicleType", List.of("车辆类型")); + aliases.put("ownerName", List.of("所有人")); + aliases.put("vin", List.of("车辆识别代码", "车辆识别代号", "车架号", "VIN")); + aliases.put("drivingLicenseNo", List.of("档案编号", "行驶证档案编号")); + aliases.put("plateColor", List.of("号牌颜色", "车牌颜色")); + aliases.put("energyType", List.of("能源种类", "能源类型")); + aliases.put("approvedLoadKg", List.of("核定载质量")); + aliases.put("tractionMassKg", List.of("准牵引总质量")); + aliases.put("compulsoryScrapDate", List.of("强制报废期止", "强制报废日期")); + aliases.put("drivingLicenseStartDate", List.of("注册日期", "发证日期")); + aliases.put("drivingLicenseEndDate", List.of("检验有效期止", "有效期止")); + return aliases; + } + + private static Map> roadTransportAliases() { + Map> aliases = new LinkedHashMap<>(); + aliases.put("roadTransportCertNo", List.of("道路运输证号", "运输证号")); + aliases.put("ownerName", List.of("业户名称", "所有人")); + aliases.put("roadTransportCertEndDate", List.of("有效期", "有效期止", "有效期至")); + return aliases; + } + + private static boolean isDateField(String key) { + return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd"); + } + + private static boolean isNumberField(String key) { + return "approvedLoadKg".equals(key) || "tractionMassKg".equals(key); + } + + private static String longTermKeyOf(String dateKey) { + if ("compulsoryScrapDate".equals(dateKey)) { + return "compulsoryScrapLongTerm"; + } + if ("drivingLicenseEndDate".equals(dateKey)) { + return "drivingLicenseLongTerm"; + } + if ("roadTransportCertEndDate".equals(dateKey)) { + return "roadTransportCertLongTerm"; + } + return null; + } + + /** + * 数字归一化:去掉「kg」「毫米」等单位与千分位后取整 + * + * @param text 识别文本 + * @return 整数;取不到合理数字返回 null + */ + private static Integer normalizeNumber(String text) { + String digits = text.replaceAll("[^0-9]", ""); + // 载质量、外廓尺寸最多 6 位;过长说明识别串了字段,宁可不填也不填错 + if (digits.isEmpty() || digits.length() > 6) { + return null; + } + return Integer.valueOf(digits); + } + + /** + * 日期归一化 + *

+ * 覆盖 {@code 2015年03月12日} / {@code 2015.3.9} / {@code 2015-03-12} / {@code 20150312} + * 这几种写法,统一成 {@code yyyy-MM-dd}。 + *

+ * 区间(如「2015.03.12-2035.03.12」「2020-01-01 至 2030-01-01」)**按字段语义取一端**: + * 以 Start 结尾的取第一段,其余(止期类)取最后一段。取错一端不会报错, + * 只会把有效期起填成止——比不填更难发现。 + * + * @param text 识别文本 + * @param key 业务字段名,用于判断该取区间的哪一端 + * @return 归一化日期;无法识别返回 null + */ + private static String normalizeDate(String text, String key) { + List dates = matchDates(text); + if (!dates.isEmpty()) { + return key.endsWith("Start") ? dates.get(0) : dates.get(dates.size() - 1); + } + String compact = text.replaceAll("[^0-9]", ""); + if (compact.length() == 8) { + return formatDate(compact); + } + return null; + } + + /** + * 取出文本里所有日期(按出现顺序) + * + * @param text 识别文本 + * @return 归一化后的日期列表 + */ + private static List matchDates(String text) { + List dates = new ArrayList<>(); + Matcher matcher = DATE_PATTERN.matcher(text); + while (matcher.find()) { + dates.add(String.format("%s-%02d-%02d", matcher.group(1), + Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)))); + } + return dates; + } + + private static String formatDate(String digits) { + return digits.substring(0, 4) + "-" + digits.substring(4, 6) + "-" + digits.substring(6, 8); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java new file mode 100644 index 00000000..83afbf81 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java @@ -0,0 +1,148 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import java.util.Locale; +import java.util.Map; + +/** + * 证件类型映射:小程序命名 → 百度 OCR 证件类型 + *

+ * ⚠️ 两侧命名对「行驶证」和「驾驶证」的用词正好相反,是本类存在的主要理由: + * + * + * + * + * + *
命名对照
证件小程序传后端 OCR 用
驾驶证{@code driver}{@code driving_license}
行驶证{@code vehicle}{@code vehicle_license}
+ * 若把 {@code vehicle} 直接透传给百度按 {@code driving_license} 处理,或反之, + * 调用方不会收到任何错误——拿到的是另一种证件的字段,问题只在人工核对时才发现。 + * 故此处用显式白名单逐一对应,而不是把请求参数直接丢给下游。 + *

+ * 采用白名单而非「透传 + 兜底为通用识别」还有一个原因: + * 从业资格证没有对应的百度接口,静默降级成通用文字识别会让前端以为识别成功。 + * + * @author Chill + */ +public final class OcrTypeMapping { + + /** + * 允许的证件类型:小程序命名与后端既有命名都收,其余一律拒绝 + */ + private static final Map TYPE_ALIASES = Map.ofEntries( + // 身份证 + Map.entry("IDCARD", BaiduOcrType.ID_CARD), + Map.entry("ID_CARD", BaiduOcrType.ID_CARD), + // 驾驶证:小程序叫 driver + Map.entry("DRIVER", BaiduOcrType.DRIVING_LICENSE), + Map.entry("DRIVING_LICENSE", BaiduOcrType.DRIVING_LICENSE), + // 行驶证:小程序叫 vehicle + Map.entry("VEHICLE", BaiduOcrType.VEHICLE_LICENSE), + Map.entry("VEHICLE_LICENSE", BaiduOcrType.VEHICLE_LICENSE), + // 道路运输证 + Map.entry("ROAD_TRANSPORT", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE), + Map.entry("ROAD_TRANSPORT_CERTIFICATE", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE) + ); + + /** + * 明确知道「没有对应 OCR 能力」的类型,单独给出可读提示 + */ + private static final Map KNOWN_UNSUPPORTED = Map.of( + "QUALIFICATION", "从业资格证" + ); + + private static final String SIDE_FRONT = "front"; + private static final String SIDE_BACK = "back"; + + private OcrTypeMapping() { + } + + /** + * 把调用方传来的证件类型转成百度 OCR 证件类型 + * + * @param type 证件类型:小程序传 idcard / driver / vehicle / road_transport / qualification, + * 也接受后端既有命名 id_card / driving_license / vehicle_license / road_transport_certificate + * @return 百度 OCR 证件类型 + * @throws IllegalArgumentException 类型为空、未知,或该证件不支持 OCR + */ + public static BaiduOcrType toBaiduType(String type) { + String normalized = normalize(type); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("OCR证件类型不能为空"); + } + BaiduOcrType baiduOcrType = TYPE_ALIASES.get(normalized); + if (baiduOcrType != null) { + return baiduOcrType; + } + String unsupportedName = KNOWN_UNSUPPORTED.get(normalized); + if (unsupportedName != null) { + // 不降级为通用识别:否则前端会拿到一堆通用文本却以为识别成功 + throw new IllegalArgumentException(unsupportedName + "暂不支持自动识别,请手动填写"); + } + throw new IllegalArgumentException("不支持的OCR证件类型:" + type); + } + + /** + * 规范化正反面参数 + *

+ * 小程序不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。 + *

+ * ⚠️ 调用方**必须原样把返回值交给下游,不要在传参前自行补默认值**: + * 这里的「默认正面」只对**支持正副面**的证件成立。道路运输证没有正副面参数, + * 若在调用方先把空值填成 {@code front} 再传下去,下游会看到「传了 side 但该证件 + * 不支持」而直接报错——本方法返回 null 才是「调用方没指定」,下游据此才分得清 + * 「默认取正面」和「这个证件根本不该带这个参数」。 + * + * @param side 正反面:front / back + * @return 规范化结果 front / back;调用方未指定时返回 null + */ + public static String normalizeSide(String side) { + String normalized = normalize(side); + if (normalized.isEmpty()) { + return null; + } + return SIDE_BACK.equals(normalized.toLowerCase(Locale.ROOT)) ? SIDE_BACK : SIDE_FRONT; + } + + /** + * 归一化:去首尾空白、去分隔符差异、统一大写 + * + * @param value 原始值 + * @return 归一化结果;空值返回空串 + */ + private static String normalize(String value) { + if (value == null) { + return ""; + } + return value.trim() + .replace('-', '_') + .replace(' ', '_') + .toUpperCase(Locale.ROOT); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/VehiclePlateSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/VehiclePlateSupport.java new file mode 100644 index 00000000..30a3b8c0 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/VehiclePlateSupport.java @@ -0,0 +1,207 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.TransportVehicle; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 司机档案与车辆表之间的车牌软关联支持类 + *

+ * 司机「驾驶车辆」与车辆表「车牌号」之间**没有外键**,只有文本关联, + * 且库中实际存在写法差异:间隔符(·、•、.)、横线、空格、大小写不一致, + * 以及一个字段里用逗号/顿号/分号/斜杠/竖线并列多辆车。 + * 认证提交时的「只能操作本人绑定车辆」校验依赖本类,因此匹配规则集中在此处维护, + * 避免各业务各自实现一套导致口径分叉。 + * + * @author Chill + */ +public final class VehiclePlateSupport { + + /** + * 并列车牌的常见分隔符(中英文顿号、逗号、分号、斜杠、竖线) + */ + private static final String SEPARATOR_PATTERN = "[,,、;;/|]"; + + /** + * 车牌中需要剔除的排版字符(各类空白、横线与间隔符) + */ + private static final String NOISE_PATTERN = "[\\s\\-·•..]"; + + private VehiclePlateSupport() { + } + + /** + * 把「驾驶车辆」字段拆成规范化后的车牌列表(已去重、保持原顺序) + * + * @param drivingVehicle 司机档案里的驾驶车辆字段,可能并列多辆 + * @return 规范化车牌列表;空值返回空列表 + */ + public static List splitPlates(String drivingVehicle) { + if (Func.isEmpty(drivingVehicle)) { + return new ArrayList<>(); + } + String normalized = drivingVehicle.replaceAll(SEPARATOR_PATTERN, ","); + Set plates = new LinkedHashSet<>(); + for (String part : Func.toStrList(",", normalized)) { + String plate = normalize(part); + if (Func.isNotEmpty(plate)) { + plates.add(plate); + } + } + return new ArrayList<>(plates); + } + + /** + * 车牌规范化:去掉排版字符并转大写,使不同写法能比对 + * + * @param plateNo 原始车牌 + * @return 规范化结果;空值返回空串 + */ + public static String normalize(String plateNo) { + if (Func.isEmpty(plateNo)) { + return ""; + } + return plateNo.trim().replaceAll(NOISE_PATTERN, "").toUpperCase(Locale.ROOT); + } + + /** + * 判定某车牌是否属于该司机绑定的车辆 + *

+ * 空值一律判为「不绑定」——不能因为双方都为空就放行,那等于越权。 + * + * @param drivingVehicle 司机档案里的驾驶车辆字段 + * @param plateNo 待判定的车牌 + * @return 是否绑定 + */ + public static boolean isBound(String drivingVehicle, String plateNo) { + String target = normalize(plateNo); + if (Func.isEmpty(target)) { + return false; + } + return splitPlates(drivingVehicle).contains(target); + } + + /** + * 一个司机最多能绑定的车辆数 + *

+ * 与小程序端「一个司机最多添加 5 辆」的上限一致。后端此前完全没有这个约束, + * 而 {@code driving_vehicle} 只有 varchar(30)——5 个车牌连分隔符要 39 字符, + * 第 4 辆起就会写不进去或被静默截断(截断后剩下半个车牌,还会被当成一辆车)。 + */ + public static final int MAX_BOUND_VEHICLES = 5; + + /** + * 车牌格式:中文省份简称 + 字母 + 5~6 位(含挂/学/警/港/澳等特殊字) + *

+ * 与车辆建档时的车牌校验共用同一条规则:能绑到司机名下的车牌, + * 必须先能作为一辆车建档,否则两处口径分叉,会出现「绑得上但建不了档」。 + * 传入值必须是 {@link #normalize} 之后的形态(已去排版字符并转大写)。 + */ + private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$"); + + /** + * 车牌格式是否合法 + * + * @param plateNo 车牌(会先做规范化) + * @return 是否合法 + */ + public static boolean isValidPlateNo(String plateNo) { + String normalized = normalize(plateNo); + return Func.isNotEmpty(normalized) && PLATE_NO_PATTERN.matcher(normalized).matches(); + } + + /** + * 校验「驾驶车辆」字段:车辆数上限与逐个车牌的格式 + *

+ * 规则只在这里维护一处——小程序新增车辆、管理端新增/编辑司机都调它。 + * 此前管理端把这个字段当作**单个**车牌校验(拿整串去车牌表里精确查), + * 而小程序写入的是逗号并列的多辆车,两边口径本就不一致: + * 一个绑了两辆车的司机,管理员在司机管理页一保存就会撞上「驾驶车辆不存在」。 + * + * @param drivingVehicle 司机档案里的驾驶车辆字段,可并列多辆 + * @throws ServiceException 车辆数超过上限,或存在格式非法的车牌 + */ + public static void validateBoundVehicles(String drivingVehicle) { + List plates = splitPlates(drivingVehicle); + if (plates.size() > MAX_BOUND_VEHICLES) { + throw new ServiceException("一个司机最多绑定" + MAX_BOUND_VEHICLES + "辆车"); + } + for (String plate : plates) { + if (!PLATE_NO_PATTERN.matcher(plate).matches()) { + throw new ServiceException("车牌号格式不正确:" + plate); + } + } + } + + /** + * 按车牌匹配车辆的 SQL 条件:精确匹配 **或** 规范化后匹配 + *

+ * ⚠️ 只做精确匹配是不够的。{@link #splitPlates} 返回的已是**规范化**车牌 + * (去空格/间隔符/横线并转大写),若直接拿它与 {@code plate_no} 原始列做 IN, + * 库中任何带间隔符或大小写差异的车牌都会**匹配不到**。 + * 后果不是「少查一条」,而是调用方据此判定「该司机名下没有可用车辆」—— + * 认证门槛会因此把**已认证的司机误判成未认证并拒绝接单**。 + *

+ * 库中已知存在这类写法差异({@code DriverAppServiceImpl.myVehicles()} 早就为此加了 + * 同样的兜底),故此处把规则收敛成一处,避免各调用点各自实现、各自漏掉。 + * + * @param wrapper 查询条件 + * @param normalizedPlates 已规范化的车牌列表 + */ + public static void applyPlateMatch(LambdaQueryWrapper wrapper, + List normalizedPlates) { + if (normalizedPlates == null || normalizedPlates.isEmpty()) { + return; + } + wrapper.and(w -> { + w.in(TransportVehicle::getPlateNo, normalizedPlates); + for (String plate : normalizedPlates) { + w.or().apply(NORMALIZED_PLATE_SQL + " = {0}", plate); + } + }); + } + + /** + * 与 {@link #normalize} 等价的 SQL 表达式(列名固定为 {@code plate_no}) + *

+ * 两者必须保持一致:Java 侧剔除的字符集变了,这里也要跟着变。 + * {@code VehiclePlateSupportTest} 会断言两边覆盖同一批字符,防止悄悄分叉。 + */ + public static final String NORMALIZED_PLATE_SQL = + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no)" + + ",'·',''),'•',''),'.',''),'.',''),'-',''),' ','')"; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 88140021..8de4c8b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -32,10 +32,14 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * 运单过程配置解析(对齐 web 端 waybill-manage / process-config) @@ -49,8 +53,18 @@ public final class WaybillProcessSupport { public static final String ACCEPT_REJECTED = "rejected"; private static final String CONFIRM_YES = "yes"; private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept"; + private static final String CONFIRM_NO_COMPLETE = "no_confirm_complete"; private static final String NODE_TRANSIT = "transit"; private static final String NODE_TRANSIT_NAME = "在途"; + /** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */ + private static final String DISPOSAL_COMPLETED = "completed"; + private static final String DISPOSAL_REVOKED = "revoked"; + /** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */ + public static final List SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED); + /** 每个打卡节点必有的照片类型,不由过程配置的凭证类型决定 */ + public static final String PUNCH_PHOTO_TYPE = "打卡照片"; + /** 单个照片类型的张数上限;打卡照片与每个凭证类型各自独立计数,不是节点合计 */ + public static final int MAX_PHOTOS_PER_TYPE = 9; private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm"); private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm"); @@ -231,6 +245,21 @@ public final class WaybillProcessSupport { return result; } + /** + * 本次打卡的节点是否是司机可打卡节点的末位。 + *

+ * 末位取「司机可打卡节点」序列:接单与回单不打卡,不参与排位—— + * 回单只要启用就必然排在配置最末,按全部启用节点取末位会永远取到回单。 + */ + public static boolean isLastDriverPunchNode(String processJson, String nodeCode) { + List> nodes = listDriverPunchNodes(processJson); + if (nodes.isEmpty()) { + return false; + } + String code = stringVal(nodeCode); + return !code.isEmpty() && code.equals(nodeKey(nodes.get(nodes.size() - 1))); + } + /** * 启用中的过程节点(按配置顺序,含接单/回单)。 */ @@ -285,14 +314,38 @@ public final class WaybillProcessSupport { return isTruthy(node.get("location")); } + /** + * 是否需要采集货量:勾了「上传货量」且至少勾了一个货量类型。 + *

+ * 以已勾选的类型为准——类型为空即不采集,不存在「没配类型所以重量/体积/数量全采集」。 + * 过程配置页对「到场」「回单」这类不采集货量的节点不提供「上传货量」开关, + * 这些节点曾被默认写成 uploadCargo=true 且 cargoTypes 为空,只看 uploadCargo 会误判为需要采集。 + */ public static boolean nodeNeedCargo(Map node) { - return isTruthy(node.get("uploadCargo")); + return isTruthy(node.get("uploadCargo")) && !nodeStringList(node, "cargoTypes").isEmpty(); } public static boolean nodeNeedVoucher(Map node) { return isTruthy(node.get("uploadVoucher")); } + /** + * 该节点的照片类型取值域:固定的「打卡照片」加过程配置勾选的凭证类型。 + *

+ * 打卡照片是每个打卡节点必有的照片类型,不由过程配置决定,与各凭证类型并列且互不替代。 + * 类型是照片分组的唯一依据,提交上来的类型必须落在这个取值域内,否则回显分组会错乱。 + */ + public static List nodePhotoTypes(Map node) { + List types = new ArrayList<>(); + types.add(PUNCH_PHOTO_TYPE); + for (String voucherType : nodeStringList(node, "voucherTypes")) { + if (!types.contains(voucherType)) { + types.add(voucherType); + } + } + return types; + } + @SuppressWarnings("unchecked") public static List nodeStringList(Map node, String field) { Object raw = node.get(field); @@ -327,6 +380,176 @@ public final class WaybillProcessSupport { return Func.isEmpty(name) ? nodeKey(node) : name; } + /** + * 指定节点因「顺序门禁」而不能打卡时,返回它前面尚未打卡的打卡节点 key;可以打卡时返回空集。 + *

+ * 「前面」按过程配置里打卡节点的排列计算;接单、回单不参与打卡,故不作为前置 + * (否则第一个打卡节点永远等不到前置打卡记录)。 + *

+ * **在途豁免顺序门禁**:它只回答「此刻在哪」,不表示某个环节完成; + * 因前置未打卡就禁止报位置,会让运管员在最需要知道车在哪的时候失去可见性。 + * + * @param processJson 过程配置 + * @param punchedNodeKeys 该运单已打卡的节点 key + * @param nodeCode 待判定的节点 key + */ + public static Set unfinishedPrerequisiteKeys( + String processJson, Collection punchedNodeKeys, String nodeCode) { + if (Func.isEmpty(nodeCode)) { + return Collections.emptySet(); + } + Set punched = punchedNodeKeys == null + ? Collections.emptySet() : new HashSet<>(punchedNodeKeys); + Set unfinished = new LinkedHashSet<>(); + for (Map node : listDriverPunchNodes(processJson)) { + // 在途不参与顺序:既不作为别人的前置,也不被别人拦 + if (isTransitNode(node)) { + continue; + } + String key = nodeKey(node); + if (nodeCode.equals(key)) { + // 走到自己:只关心它前面那些 + return unfinished; + } + if (!punched.contains(key)) { + unfinished.add(key); + } + } + // 节点不在本配置里:不做顺序判定,交给节点存在性校验去报错 + return Collections.emptySet(); + } + + /** + * 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。 + *

+ * 空与空不算相等——节点编码为空表示整单,它不归属任何过程节点。 + */ + public static boolean sameNodeCode(String left, String right) { + String leftCode = stringVal(left); + String rightCode = stringVal(right); + return !leftCode.isEmpty() && leftCode.equalsIgnoreCase(rightCode); + } + + /** + * 该异常是否为整单异常:异常不归属任何过程节点时,节点编码为空。 + */ + public static boolean isWaybillLevelException(String exceptionNodeCode) { + return stringVal(exceptionNodeCode).isEmpty(); + } + + /** + * 该异常是否与某节点相关。 + *

+ * 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。 + * 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。 + */ + public static boolean relatesToNode(String nodeKey, String exceptionNodeCode) { + return isWaybillLevelException(exceptionNodeCode) || sameNodeCode(nodeKey, exceptionNodeCode); + } + + /** + * 过程配置里的在途节点 key;配置中无在途节点时返回空串。 + */ + public static String transitNodeKey(String processJson) { + for (Map node : listDriverPunchNodes(processJson)) { + if (isTransitNode(node)) { + return nodeKey(node); + } + } + return ""; + } + + /** + * 计算被异常门禁阻断的过程节点 key。 + *

+ * 普通打卡节点按区间阻断:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。 + * 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。 + *

+ * **在途节点按运单级阻断**:该运单只要存在任何未了结异常,在途就不可打卡, + * 与异常落在哪个节点无关。在途只回答「此刻在哪」,不表示某个环节完成, + * 区间对它没有意义——按区间算的话,异常报在「装货」(在途之前)会锁住在途, + * 报在「到货」(在途之后)反而放行,等于越接近目的地越能报在途。 + *

+ * 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。 + * 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。 + * + * @param processJson 过程配置 + * @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常 + */ + public static Set blockedNodeKeys(String processJson, Collection unresolvedNodeCodes) { + Set blocked = new LinkedHashSet<>( + blockedNodeKeysFrom(processJson, earliestBlockingNodeCode(processJson, unresolvedNodeCodes))); + if (unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return blocked; + } + String transitKey = transitNodeKey(processJson); + if (Func.isNotEmpty(transitKey)) { + blocked.add(transitKey); + } + return blocked; + } + + /** + * 取「最早的可打卡节点」上的未了结异常节点 key;未指明节点的异常(空串)优先。 + *

+ * 返回值供按 key 反查具体异常用;无异常可阻断时返回 null。 + */ + public static String earliestBlockingNodeCode(String processJson, Collection unresolvedNodeCodes) { + List> punchNodes = listDriverPunchNodes(processJson); + if (punchNodes.isEmpty() || unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return null; + } + Set reported = new HashSet<>(); + for (String code : unresolvedNodeCodes) { + String key = stringVal(code); + // 未指明节点的异常锁全单,优先级高于任何具体节点 + if (Func.isEmpty(key)) { + return ""; + } + reported.add(key); + } + for (Map node : punchNodes) { + String key = nodeKey(node); + if (reported.contains(key)) { + return key; + } + } + return null; + } + + /** + * 由某个起点节点计算阻断区间:该节点及其后所有节点。起点为 null 时返回空集。 + */ + public static Set blockedNodeKeysFrom(String processJson, String fromNodeCode) { + List> punchNodes = listDriverPunchNodes(processJson); + Set blocked = new LinkedHashSet<>(); + if (punchNodes.isEmpty() || fromNodeCode == null) { + return blocked; + } + // 空串代表「未指明节点」——全局入口(首页快捷格、打卡页底部按钮)不指定环节, + // 这类异常无从定位区间,只能阻断全部可打卡节点。 + if (Func.isEmpty(fromNodeCode)) { + for (Map node : punchNodes) { + blocked.add(nodeKey(node)); + } + return blocked; + } + int start = -1; + for (int i = 0; i < punchNodes.size(); i++) { + if (fromNodeCode.equals(nodeKey(punchNodes.get(i)))) { + start = i; + break; + } + } + if (start < 0) { + return blocked; + } + for (int i = start; i < punchNodes.size(); i++) { + blocked.add(nodeKey(punchNodes.get(i))); + } + return blocked; + } + @SuppressWarnings("unchecked") public static List> parseProcessNodes(String processJson) { if (Func.isEmpty(processJson)) { @@ -370,10 +593,48 @@ public final class WaybillProcessSupport { return "return".equals(key) || "回单".equals(name); } + /** + * 是否接单节点:key=accept 或 name=接单。 + *

+ * 管理端打卡时间轴与补录校验共用它——接单节点的完成由运单的接单事实决定, + * 不落打卡流水,两处的判据必须是同一个。 + */ + public static boolean isAcceptNode(String nodeCode, String nodeName) { + return "accept".equals(nodeCode) || "接单".equals(nodeName); + } + private static boolean isAcceptNode(Map node) { - String key = stringVal(node.get("key")); - String name = stringVal(node.get("name")); - return "accept".equals(key) || "接单".equals(name); + return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name"))); + } + + /** + * 是否到货 / 卸货 / 签收节点:key 或中文名任一命中即算。 + */ + public static boolean isDeliveryNode(String nodeCode, String nodeName) { + String key = stringVal(nodeCode); + String name = stringVal(nodeName); + return "arrival".equals(key) || "到货".equals(name) + || "unload".equals(key) || "卸货".equals(name) + || "sign".equals(key) || "签收".equals(name); + } + + /** + * 回单是否无需确认完成:配置里没有回单节点、回单未启用,或启用且配置为「无需确认完成」。 + *

+ * 未启用的回单节点不看 confirmMode——没有回单环节就不会有人来确认; + * 启用但 confirmMode 缺失按「需确认完成」算。 + */ + public static boolean returnNeedsNoConfirmation(String processJson) { + for (Map node : parseProcessNodes(processJson)) { + if (!isReturnNode(node)) { + continue; + } + if (!isEnabled(node)) { + return true; + } + return CONFIRM_NO_COMPLETE.equals(stringVal(node.get("confirmMode"))); + } + return true; } private static boolean isTransitNode(Map node) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java new file mode 100644 index 00000000..f6a8d7e2 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java @@ -0,0 +1,76 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.wrapper; + +import org.springblade.core.mp.support.BaseEntityWrapper; +import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; +import org.springblade.transport.support.CertificationRuleSupport; + +import java.util.Objects; + +/** + * 认证记录包装类 + * + * @author Chill + */ +public class TransportCertRecordWrapper extends BaseEntityWrapper { + + public static TransportCertRecordWrapper build() { + return new TransportCertRecordWrapper(); + } + + @Override + public TransportCertRecordVO entityVO(TransportCertRecord transportCertRecord) { + TransportCertRecordVO transportCertRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(transportCertRecord, TransportCertRecordVO.class)); + return fillDisplayFields(transportCertRecordVO); + } + + /** + * 填充面向前端的展示字段:四个姓名与状态文案 + *

+ * 审核列表走的是自定义 SQL,查出来已经是 VO——它多带司机姓名与手机号两个字段, + * 这两个字段实体上没有、无法由实体转换得到,所以那条路径不能走 {@link #entityVO}。 + * 把填充逻辑单独暴露一次,避免 Service 里再复制一份翻译规则,日后两处走偏。 + * + * @param transportCertRecordVO 认证记录视图对象 + * @return 同一个对象,便于链式调用 + */ + public TransportCertRecordVO fillDisplayFields(TransportCertRecordVO transportCertRecordVO) { + // create/update/submit 可能来自小程序用户;audit 一般为后台系统用户 + transportCertRecordVO.setCreateUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getCreateUser())); + transportCertRecordVO.setUpdateUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getUpdateUser())); + transportCertRecordVO.setSubmitUserName(UserDisplayCache.getUserRealName(transportCertRecordVO.getSubmitUser())); + transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecordVO.getAuditUser())); + // 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法 + transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecordVO.getStatus())); + return transportCertRecordVO; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java index cf77a825..05e77a5b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java @@ -48,7 +48,19 @@ public class TransportVehicleWrapper extends BaseEntityWrapper { waybillVO.setBusinessStatusName(businessStatusName(displayStatus)); waybillVO.setTransportTypeName(transportTypeName(waybill.getTransportType())); waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); - // 未维护里程等占位值 -1 对外展示为空 + // 未维护里程/费用等占位值 -1 对外展示为空 if (isSentinelMinusOne(waybillVO.getMileage())) { waybillVO.setMileage(null); } @@ -72,6 +72,9 @@ public class WaybillWrapper extends BaseEntityWrapper { if (isSentinelMinusOne(waybillVO.getUnitPrice())) { waybillVO.setUnitPrice(null); } + if (isSentinelMinusOne(waybillVO.getOtherFeeTotal())) { + waybillVO.setOtherFeeTotal(null); + } // 计划未选时 planId 可能为库内 -1 或序列化占位,统一清空避免前端回显 -1 if (waybillVO.getPlanId() != null && waybillVO.getPlanId() <= 0L) { waybillVO.setPlanId(null); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/TestInfraSmokeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/TestInfraSmokeTest.java new file mode 100644 index 00000000..27b727d4 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/TestInfraSmokeTest.java @@ -0,0 +1,17 @@ +package org.springblade.transport; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 测试基础设施自检:确认 JUnit 5 能在本模块被发现并执行。 + */ +class TestInfraSmokeTest { + + @Test + void junit5Runs() { + assertEquals(4, 2 + 2); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java new file mode 100644 index 00000000..81bbb9e1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java @@ -0,0 +1,136 @@ +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.mockito.ArgumentCaptor; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.ICustomerArchiveService; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +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; + +/** + * 打卡记录编辑接口的入参绑定测试。 + *

+ * 用真实的 HTTP 请求走一遍路由与 JSON 反序列化:字段名写错、凭证图数组结构对不上, + * 都会在这里失败,而不是等前端点保存时才发现。 + */ +class WaybillPunchRecordEndpointTest { + + private IWaybillService waybillService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + WaybillController controller = new WaybillController( + waybillService, + mock(IProjectApplyService.class), + mock(IContractManageService.class), + mock(ICustomerArchiveService.class), + mock(ITransportPlanService.class), + mock(IWaybillImportBatchService.class)); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + @DisplayName("POST /waybill-manage/punch-record/save:绑定打卡明细与凭证图") + void bindPunchRecordBody() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(true); + String body = """ + { + "waybillId": 6001, + "type": "node", + "nodeCode": "load", + "nodeName": "装货", + "punched": true, + "punchTime": "2026-10-06 09:30:00", + "address": "上海市青浦区", + "longitude": "121.47", + "latitude": "31.23", + "weight": "12.5", + "volume": "", + "quantity": "3", + "remark": "补录", + "photos": [{"type": "委托单", "url": "https://oss/a.jpg"}] + } + """; + + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.success").value(true)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class); + verify(waybillService).savePunchRecord(captor.capture()); + WaybillPunchRecordSaveRequest request = captor.getValue(); + assertEquals(6001L, request.getWaybillId()); + assertEquals("node", request.getType()); + assertEquals("load", request.getNodeCode()); + assertEquals("装货", request.getNodeName()); + assertEquals(Boolean.TRUE, request.getPunched()); + assertEquals("2026-10-06 09:30:00", request.getPunchTime()); + assertEquals("上海市青浦区", request.getAddress()); + assertEquals("121.47", request.getLongitude()); + assertEquals("31.23", request.getLatitude()); + assertEquals("12.5", request.getWeight()); + assertEquals("3", request.getQuantity()); + assertEquals("补录", request.getRemark()); + assertNotNull(request.getPhotos()); + assertEquals(1, request.getPhotos().size()); + assertEquals("委托单", request.getPhotos().get(0).getType()); + assertEquals("https://oss/a.jpg", request.getPhotos().get(0).getUrl()); + } + + @Test + @DisplayName("取消打卡:punched=false 原样传入服务层") + void bindUnpunchedFlag() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(true); + String body = """ + {"waybillId": 6001, "type": "enroute", "punched": false} + """; + + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class); + verify(waybillService).savePunchRecord(captor.capture()); + assertEquals(Boolean.FALSE, captor.getValue().getPunched()); + assertEquals("enroute", captor.getValue().getType()); + } + + @Test + @DisplayName("服务层返回 false:响应标记为失败") + void reportFailureWhenServiceRejects() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(false); + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"waybillId\": 6001}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java new file mode 100644 index 00000000..e570361d --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -0,0 +1,475 @@ +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.update.LambdaUpdateWrapper; +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.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.CertAuditDetailVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.util.Collection; + +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.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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 管理端认证审核测试。 + *

+ * 守住两条最容易写坏的不变式: + * 驳回**不得改变**生效快照;已终态记录**不可再审**(再审会把生效快照改坏)。 + */ +class CertAuditServiceImplTest { + + private static final Long RECORD_ID = 5001L; + private static final Long VEHICLE_ID = 8001L; + private static final Long DRIVER_ID = 9001L; + + private ITransportCertRecordService certRecordService; + private TransportCertRecordMapper certRecordMapper; + private ITransportVehicleService transportVehicleService; + private IDriverService driverService; + private CertAuditServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @BeforeEach + void setUp() { + certRecordService = mock(ITransportCertRecordService.class); + certRecordMapper = mock(TransportCertRecordMapper.class); + transportVehicleService = mock(ITransportVehicleService.class); + driverService = mock(IDriverService.class); + service = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService); + // 目标对象的写入默认成功;「写入一行都没命中」由专门的用例覆盖 + when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(true); + when(driverService.updateById(any(Driver.class))).thenReturn(true); + } + + // ------------------------------------------------------------ 通过 + + @Test + @DisplayName("通过:记录转已通过,并把该对象的生效快照指针切到它") + void approveSwitchesEffectivePointer() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(recordCaptor.capture()); + assertEquals(TransportCertRecord.STATUS_APPROVED, recordCaptor.getValue().getStatus()); + assertNull(recordCaptor.getValue().getRejectReason(), "通过时必须清空驳回原因"); + assertNotNull(recordCaptor.getValue().getAuditTime(), "审核时间必须留痕"); + assertNotNull(recordCaptor.getValue().getAuditUser(), "审核人必须留痕"); + + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(vehicleCaptor.capture()); + assertEquals(RECORD_ID, vehicleCaptor.getValue().getEffectiveCertRecordId(), + "通过后指针应指向该记录"); + } + + @Test + @DisplayName("通过车辆认证:把快照的证件内容写回车辆档案,让「档案 = 生效值」成立") + void approveWritesSnapshotBackToVehicle() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + TransportVehicle patched = captor.getValue(); + assertEquals("桂物物流集团", patched.getOwnerName(), "所有人要跟着生效值更新"); + assertEquals("管理挂靠", patched.getBusinessRelation()); + assertEquals(18000, patched.getApprovedLoadKg()); + assertEquals(RECORD_ID, patched.getEffectiveCertRecordId(), "内容与指针必须是同一次 UPDATE"); + assertNull(patched.getPlateNo(), "车牌是车辆身份,不是本次提交能改的内容"); + assertNull(patched.getOrganizationName(), "所属组织由管理端维护,不该被提交内容覆盖"); + } + + @Test + @DisplayName("通过车辆认证:快照是旧格式时明确失败,不能「通过但什么也没写」") + void approveRejectsLegacySnapshot() { + // 旧快照的键名与当前字段定义不同,会解析成「全是 null」而不报错——最危险的一种情况 + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING, + "{\"licenseNo\":\"450100123456\",\"licenseFrontUrl\":\"http://oss/old.jpg\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("字段定义不匹配"), exception.getMessage()); + verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); + } + + @Test + @DisplayName("通过:切换指针而不是追加,同对象仍只有一条生效快照") + void approveReplacesPointerInsteadOfAppending() { + Long oldEffectiveId = 111L; + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId()); + // 只更新了这一个字段所在的车辆行,没有新增任何"生效标记"行 + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + } + + @Test + @DisplayName("通过:同步刷新展示缓存为已通过") + void approveRefreshesDisplayCache() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals(TransportCertRecord.STATUS_APPROVED, captor.getValue().getCertificationStatus()); + } + + @Test + @DisplayName("通过司机认证:指针写到司机表") + void approveDriverSwitchesDriverPointer() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId()); + } + + @Test + @DisplayName("通过司机认证:patch 必须带上车辆绑定,不得把 driving_vehicle 清空") + void approveDriverKeepsDrivingVehicle() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + Driver existing = driver(null); + existing.setDrivingVehicle("桂A12345,桂B67890"); + when(driverService.getById(DRIVER_ID)).thenReturn(existing); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + // driving_vehicle 标了 FieldStrategy.ALWAYS,patch 里为 null 就会把该列写成 NULL; + // 而司机端车辆列表正是按这一列匹配的,清空即「暂无绑定车辆」且界面上无法恢复 + assertEquals("桂A12345,桂B67890", captor.getValue().getDrivingVehicle(), + "审核通过不得把司机的车辆绑定清空"); + } + + @Test + @DisplayName("通过:目标对象一行都没命中时必须整体失败,不得「记录已通过、档案没变」") + void approveFailsWhenVehicleNotUpdated() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(false); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("未生效"), exception.getMessage()); + } + + @Test + @DisplayName("通过司机认证:司机档案没命中时同样必须整体失败,不得静默放行") + void approveFailsWhenDriverNotUpdated() { + when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING)); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + when(driverService.updateById(any(Driver.class))).thenReturn(false); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("司机"), exception.getMessage()); + } + + // ------------------------------------------------------------ 驳回 + + @Test + @DisplayName("驳回:记录转已驳回并留原因,但**生效快照与可用性不变**(核心不变式)") + void rejectKeepsEffectivePointer() { + Long existingEffectiveId = 777L; + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); + + service.audit(RECORD_ID, false, "证件照片模糊"); + + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(recordCaptor.capture()); + assertEquals(TransportCertRecord.STATUS_REJECTED, recordCaptor.getValue().getStatus()); + assertEquals("证件照片模糊", recordCaptor.getValue().getRejectReason()); + + // 展示缓存可以更新,但**指针绝不能被改动** + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(vehicleCaptor.capture()); + assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(), + "驳回不得修改生效快照指针,否则已认证的车会被无辜下线"); + assertEquals(TransportCertRecord.STATUS_REJECTED, vehicleCaptor.getValue().getCertificationStatus()); + } + + @Test + @DisplayName("驳回:已认证的对象被驳回后,生效快照指针仍指向原来那条") + void rejectNeverClearsExistingPointer() { + Long existingEffectiveId = 777L; + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); + + service.audit(RECORD_ID, false, "运输证过期"); + + // 指针字段没有被写成 null,也没有被写成新记录 + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertNull(captor.getValue().getEffectiveCertRecordId()); + } + + @Test + @DisplayName("驳回:不填原因必须拒绝") + void rejectRequiresReason() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null)); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " ")); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("驳回:原因超过 200 字必须拒绝(与列宽一致)") + void rejectReasonTooLong() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + + assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, false, "长".repeat(201))); + } + + // ------------------------------------------------------------ 终态保护 + + @Test + @DisplayName("终态不可再审:已通过/已驳回/已作废一律拒绝") + void terminalRecordsCannotBeAuditedAgain() { + for (Integer terminal : new Integer[]{ + TransportCertRecord.STATUS_APPROVED, + TransportCertRecord.STATUS_REJECTED, + TransportCertRecord.STATUS_SUPERSEDED}) { + TransportCertRecord record = vehicleRecord(terminal); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null), + "状态 " + terminal + " 属终态,不应可再审"); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"), + "状态 " + terminal + " 属终态,不应可再审"); + } + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("记录不存在:拒绝") + void recordNotFound() { + when(certRecordService.getById(RECORD_ID)).thenReturn(null); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null)); + } + + @Test + @DisplayName("记录主键为空:拒绝") + void recordIdRequired() { + assertThrows(ServiceException.class, () -> service.audit(null, true, null)); + } + + // ------------------------------------------------------------ 审核详情(diff 用) + + @Test + @DisplayName("审核详情:同时给出待审快照与当前生效值,供前端做 diff") + void detailProvidesBothSides() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson("{\"vin\":\"NEW\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + + TransportCertRecord oldEffective = new TransportCertRecord(); + oldEffective.setId(777L); + oldEffective.setSnapshotJson("{\"vin\":\"OLD\"}"); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + when(certRecordService.getById(777L)).thenReturn(oldEffective); + + CertAuditDetailVO detail = service.detail(RECORD_ID); + + assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson()); + assertEquals("{\"vin\":\"OLD\"}", detail.getEffectiveSnapshotJson(), + "审核页需要旧生效值才能看出司机改了什么"); + assertEquals(777L, detail.getEffectiveRecordId()); + } + + @Test + @DisplayName("审核详情:从未认证过时,生效值一侧为 null(而不是报错)") + void detailWithoutEffective() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson("{\"vin\":\"NEW\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + CertAuditDetailVO detail = service.detail(RECORD_ID); + + assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson()); + assertNull(detail.getEffectiveSnapshotJson()); + assertNull(detail.getEffectiveRecordId()); + } + + // -------------------------------------------- 驳回原因的展示缓存 + + @Test + @DisplayName("通过车辆认证:显式清空档案上的驳回原因(not_null 策略会让 null 到不了 SQL)") + @SuppressWarnings("unchecked") + void approveClearsStaleVehicleRejectReason() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(transportVehicleService).update(captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null(该列无原因时的取值)"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == VEHICLE_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + + @Test + @DisplayName("通过司机认证:同样显式清空驳回原因,且不顺带动车辆绑定") + @SuppressWarnings("unchecked") + void approveClearsStaleDriverRejectReason() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null)); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + assertFalse(wrapper.getSqlSet().contains("driving_vehicle"), + "清空驳回原因不得顺带动车辆绑定"); + } + + @Test + @DisplayName("驳回:原因去空白后写入档案,且不发清空语句") + void rejectWritesReasonAndDoesNotClear() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + + service.audit(RECORD_ID, false, " 行驶证模糊 "); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals("行驶证模糊", captor.getValue().getCertificationRejectReason(), + "驳回原因去首尾空白后落库"); + verify(transportVehicleService, never()).update(any()); + } + + // ------------------------------------------------------------ 辅助 + + private TransportCertRecord vehicleRecord(Integer status) { + return vehicleRecord(status, completeVehicleSnapshot()); + } + + private TransportCertRecord vehicleRecord(Integer status, String snapshotJson) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(RECORD_ID); + record.setBizType(TransportCertRecord.BIZ_TYPE_VEHICLE); + record.setBizId(VEHICLE_ID); + record.setBizKey("桂A32523"); + record.setStatus(status); + record.setSnapshotJson(snapshotJson); + return record; + } + + /** + * 一份字段齐全的车辆认证快照 + *

+ * 审核通过时会把快照内容写回车辆档案,故用例不能再拿空快照走通过流程; + * 关键字段(档案编号、核定载质量)是「这份快照是否为当前格式」的判据。 + */ + static String completeVehicleSnapshot() { + return "{\"drivingLicenseNo\":\"450100123456\",\"approvedLoadKg\":18000," + + "\"ownerName\":\"桂物物流集团\",\"businessRelation\":\"管理挂靠\"," + + "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}"; + } + + private TransportCertRecord driverRecord(Integer status) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(RECORD_ID); + record.setBizType(TransportCertRecord.BIZ_TYPE_DRIVER); + record.setBizId(DRIVER_ID); + record.setBizKey("张三"); + record.setStatus(status); + record.setSnapshotJson("{}"); + return record; + } + + private TransportVehicle vehicle(Long effectiveId) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(VEHICLE_ID); + vehicle.setPlateNo("桂A32523"); + vehicle.setEffectiveCertRecordId(effectiveId); + return vehicle; + } + + private Driver driver(Long effectiveId) { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setDriverName("张三"); + driver.setEffectiveCertRecordId(effectiveId); + return driver; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java new file mode 100644 index 00000000..9878ad99 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java @@ -0,0 +1,181 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.DriverVehicleCardVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 司机端车辆卡片(重新认证表单的数据来源)测试。 + *

+ * 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约: + * 日期字段只能是 {@code yyyy-MM-dd} 或空串(不能是「长期」这类文字); + * 标了长期有效的字段不能再下发一个作废的止期。 + */ +class DriverAppServiceImplTest { + + private static final String MOBILE = "15577122721"; + private static final Long USER_ID = 7001L; + + private IDriverService driverService; + private ITransportVehicleService transportVehicleService; + private DriverAppServiceImpl service; + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + transportVehicleService = mock(ITransportVehicleService.class); + service = new DriverAppServiceImpl(driverService, transportVehicleService, mock(IMpUserClient.class)); + } + + @Test + @DisplayName("长期有效且没有止期时下发空串,不能下发「长期」——否则它会被当日期填进日期框") + void longTermWithoutEndDateSendsEmptyText() { + DriverVehicleCardVO card = firstCard(vehicle(LocalDate.of(2022, 8, 17), null, 1)); + + assertEquals("", card.getDrivingLicenseEndDate(), "长期有效时日期字段只能是空串"); + assertEquals(1, card.getDrivingLicenseLongTerm(), "长期有效标志本身要照常下发"); + assertEquals("2022-08-17", card.getDrivingLicenseStartDate(), "起期有值时照常下发"); + } + + @Test + @DisplayName("长期有效时即使库里还留着止期也不下发:那个止期是上一版证件作废的值") + void longTermWithStaleEndDateSendsEmptyText() { + DriverVehicleCardVO card = firstCard(vehicle(null, LocalDate.of(2040, 8, 14), 1)); + + assertEquals("", card.getDrivingLicenseEndDate()); + assertEquals("", card.getRoadTransportCertEndDate()); + assertEquals("", card.getCompulsoryScrapDate()); + } + + @Test + @DisplayName("未标长期有效时照常下发止期,空值一律是空串而不是 null 字面量") + void normalVehicleSendsRealDates() { + TransportVehicle vehicle = vehicle(null, LocalDate.of(2030, 8, 14), 0); + vehicle.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1)); + vehicle.setRoadTransportCertEndDate(null); + vehicle.setRoadTransportCertLongTerm(0); + vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 4)); + DriverVehicleCardVO card = firstCard(vehicle); + + assertEquals("2030-08-14", card.getDrivingLicenseEndDate()); + assertEquals("2035-01-01", card.getCompulsoryScrapDate()); + assertEquals("", card.getRoadTransportCertEndDate(), "没填的止期是空串"); + assertEquals("2028-12-04", card.getRoadTransportCertStartDate()); + } + + // ---------------------------------------------------------------- 手机号解析 + + @Test + @DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案") + void twelveDigitAccountIsUsableAsPhone() { + assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName()); + } + + @Test + @DisplayName("小程序用户 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权") + void maskedUserPhoneFallsBackToAccount() { + MpUser user = new MpUser(); + user.setPhone("175****12082"); + user.setAccount("175874212082"); + + assertEquals("黄大富", currentByPhone(null, null, user).getDriverName()); + } + + @Test + @DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案") + void otherPeoplesMobileIsRejected() { + assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class, + () -> currentByPhone("13900000001", "175874212082", null)).getMessage()); + } + + // ---------------------------------------------------------------- 辅助 + + /** 造一辆只关心有效期三个字段的车 */ + private TransportVehicle vehicle(LocalDate licenseStart, LocalDate licenseEnd, Integer licenseLongTerm) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(8001L); + vehicle.setPlateNo("桂AAW3255"); + vehicle.setDrivingLicenseStartDate(licenseStart); + vehicle.setDrivingLicenseEndDate(licenseEnd); + vehicle.setDrivingLicenseLongTerm(licenseLongTerm); + // 三组有效期都按同一个长期有效标志造,便于一次断言三处 + vehicle.setCompulsoryScrapDate(null); + vehicle.setCompulsoryScrapLongTerm(licenseLongTerm); + vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 3)); + vehicle.setRoadTransportCertEndDate(LocalDate.of(2043, 8, 18)); + vehicle.setRoadTransportCertLongTerm(licenseLongTerm); + return vehicle; + } + + /** 按当前登录司机的手机号跑一次「我的车辆」,取第一张卡片 */ + private DriverVehicleCardVO firstCard(TransportVehicle vehicle) { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDriverName("韦司机"); + driver.setMobile(MOBILE); + driver.setDrivingVehicle(vehicle.getPlateNo()); + + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of(vehicle)); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + // 账号本身就是手机号,resolvePhone 走这条短路分支,不再回调用户中心 + authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE); + List cards = service.myVehicles(); + assertEquals(1, cards.size(), "应当只返回绑定到本人的这一辆车"); + return cards.get(0); + } + } + + /** + * 按给定的登录态跑一次 currentByPhone。 + * + * @param mobileHint 前端传入的 mobile,可为 null + * @param account JWT 里的账号,可为 null + * @param user 小程序用户,可为 null(表示走不到小程序用户中心) + */ + private Driver currentByPhone(String mobileHint, String account, MpUser user) { + IMpUserClient mpUserClient = mock(IMpUserClient.class); + if (user != null) { + when(mpUserClient.userById(USER_ID)).thenReturn(R.data(user)); + } + DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, mpUserClient); + + Driver driver = new Driver(); + driver.setId(9002L); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setMobile("175874212082"); + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUserAccount).thenReturn(account); + return scoped.currentByPhone(mobileHint); + } + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java new file mode 100644 index 00000000..505f57a1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -0,0 +1,715 @@ +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.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.alibaba.fastjson2.JSON; +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.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.service.IDriverAppService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机端认证提交链路测试。 + *

+ * 重点守住票 02 的三条验收点与不变式: + * 提交不得改变生效快照、同对象至多一条待审、越权必须拒绝。 + */ +class DriverCertServiceImplTest { + + private static final Long DRIVER_ID = 9001L; + private static final Long USER_ID = 7001L; + private static final Long VEHICLE_ID = 8001L; + private static final String PLATE = "桂A32523"; + + private IDriverAppService driverAppService; + private IDriverService driverService; + private ITransportVehicleService transportVehicleService; + private ITransportCertRecordService certRecordService; + private DriverCertServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @BeforeEach + void setUp() { + driverAppService = mock(IDriverAppService.class); + driverService = mock(IDriverService.class); + transportVehicleService = mock(ITransportVehicleService.class); + certRecordService = mock(ITransportCertRecordService.class); + service = new DriverCertServiceImpl(driverAppService, driverService, + transportVehicleService, certRecordService); + } + + // ---------------------------------------------------------------- 车辆提交 + + @Test + @DisplayName("车辆提交:落一条待审记录,且状态必须是待审(不能被框架默认值变成已通过)") + void submitVehicleCreatesPendingRecord() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)) + .thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + request.setVin("VIN123"); + + service.submitVehicleCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(captor.capture()); + TransportCertRecord saved = captor.getValue(); + + assertEquals(TransportCertRecord.STATUS_PENDING, saved.getStatus()); + assertEquals(TransportCertRecord.BIZ_TYPE_VEHICLE, saved.getBizType()); + assertEquals(VEHICLE_ID, saved.getBizId()); + assertEquals(PLATE, saved.getBizKey()); + assertTrue(saved.getSnapshotJson().contains("VIN123"), "快照应承载本次提交的全量内容"); + assertNull(saved.getRejectReason(), "新提交不得继承上一条的驳回原因"); + } + + @Test + @DisplayName("提交人必须记用户ID:记成司机档案ID会让审核页「提交人」显示一串数字") + void submitRecordsUserIdAsSubmitUser() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest vehicleRequest = completeVehicleRequest(PLATE); + vehicleRequest.setId(VEHICLE_ID); + service.submitVehicleCert(vehicleRequest); + + PersonCertSubmitRequest personRequest = new PersonCertSubmitRequest(); + personRequest.setRealName("张三"); + service.submitPersonCert(personRequest); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService, times(2)).save(captor.capture()); + + for (TransportCertRecord saved : captor.getAllValues()) { + assertEquals(USER_ID, saved.getSubmitUser(), + "提交人必须是用户ID(审核页靠它翻译姓名),不能是司机档案ID"); + assertNotEquals(saved.getBizId(), saved.getSubmitUser(), + "提交人与认证对象主键是两种ID,不应相同"); + } + } + + @Test + @DisplayName("车辆提交:不修改生效快照指针(提交不影响车辆可用性)") + void submitVehicleDoesNotTouchEffectivePointer() { + + Long effectiveId = 777L; + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, PLATE, effectiveId)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + service.submitVehicleCert(request); + + // 更新车辆时只能用展示缓存字段,且绝不能带上 effectiveCertRecordId + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + TransportVehicle updated = captor.getValue(); + assertNull(updated.getEffectiveCertRecordId(), + "提交链路不得写生效快照指针,否则「曾通过即永久可用」会被破坏"); + assertEquals(TransportCertRecord.STATUS_PENDING, updated.getCertificationStatus()); + } + + @Test + @DisplayName("车辆提交:前一条待审被置为已作废,不存在两条待审") + void submitVehicleSupersedesPreviousPending() { + TransportCertRecord previous = record(500L, TransportCertRecord.STATUS_PENDING); + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)) + .thenReturn(previous); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + service.submitVehicleCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, captor.getValue().getStatus()); + assertEquals(500L, captor.getValue().getId(), "被作废的应是原来那条待审"); + } + + @Test + @DisplayName("车辆提交:前一条已是终态时不得被改动(尤其不能碰已通过的那条)") + void submitVehicleLeavesTerminalRecordsAlone() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L)); + // selectPendingRecord 只返回待审;已通过的不该被它返回 + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + service.submitVehicleCert(request); + + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + // ---------------------------------------------------------------- 越权 + + @Test + @DisplayName("越权:提交未绑定到自己名下的车辆必须拒绝") + void submitVehicleRejectsUnboundVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, "桂B99999", null)); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + + assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("越权:车牌写法差异不影响绑定判定(间隔符/大小写)") + void submitVehicleAcceptsBoundVehicleWithMessyPlate() { + // 司机档案里带间隔符,车辆表里是干净写法 + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523")); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523"); + request.setId(VEHICLE_ID); + + service.submitVehicleCert(request); + verify(certRecordService).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("越权:司机未绑定任何车辆时提交必须拒绝") + void submitVehicleRejectsWhenNoBoundVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(null)); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + + assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); + } + + @Test + @DisplayName("越权:不可查看他人车辆的认证记录列表") + void listRecordsRejectsOthersVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, "桂B99999", null)); + + assertThrows(ServiceException.class, + () -> service.listRecords(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)); + } + + @Test + @DisplayName("越权:不可查看其他司机的认证记录列表") + void listRecordsRejectsOtherDriver() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + + assertThrows(ServiceException.class, + () -> service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, 12345L)); + } + + // ---------------------------------------------------------------- 个人认证 + + @Test + @DisplayName("个人提交:落待审记录,且再提交时作废前一条待审") + void submitPersonCertSupersedes() { + TransportCertRecord previous = record(600L, TransportCertRecord.STATUS_PENDING); + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID)) + .thenReturn(previous); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + request.setIdCardNo("110101199001011234"); + + service.submitPersonCert(request); + + ArgumentCaptor saved = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(saved.capture()); + assertEquals(TransportCertRecord.STATUS_PENDING, saved.getValue().getStatus()); + assertEquals(TransportCertRecord.BIZ_TYPE_DRIVER, saved.getValue().getBizType()); + assertEquals(DRIVER_ID, saved.getValue().getBizId()); + + ArgumentCaptor updated = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(updated.capture()); + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, updated.getValue().getStatus()); + } + + @Test + @DisplayName("个人提交:不写生效快照指针(只刷展示缓存)") + void submitPersonCertDoesNotTouchEffectivePointer() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + service.submitPersonCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + assertNull(captor.getValue().getEffectiveCertRecordId(), + "提交链路不得写生效快照指针"); + } + + @Test + @DisplayName("个人提交:清空上一次的驳回原因(null 交给 updateById 到不了 SQL)") + @SuppressWarnings("unchecked") + void submitPersonCertClearsStaleRejectReason() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + request.setIdCardNo("110101199001011234"); + + service.submitPersonCert(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "重新提交时旧驳回原因必须清掉,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + + @Test + @DisplayName("个人提交:内容为空必须拒绝") + void submitPersonCertRejectsEmpty() { + assertThrows(ServiceException.class, () -> service.submitPersonCert(null)); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + // ---------------------------------------------------------------- 生效值读取 + + @Test + @DisplayName("读生效值:有指针时返回指针指向的那条快照内容(不是最新提交)") + void getEffectivePersonCertReadsPointedRecord() { + Long effectiveId = 777L; + Driver driver = driver(PLATE); + driver.setEffectiveCertRecordId(effectiveId); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + + PersonCertSubmitRequest effectiveContent = new PersonCertSubmitRequest(); + effectiveContent.setRealName("旧生效值"); + TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED); + effective.setSnapshotJson(JSON.toJSONString(effectiveContent)); + when(certRecordService.getById(effectiveId)).thenReturn(effective); + + PersonCertSubmitRequest result = service.getEffectivePersonCert(); + + assertEquals("旧生效值", result.getRealName()); + } + + @Test + @DisplayName("读生效值:从未认证通过时返回 null(而不是抛错或返回最新提交)") + void getEffectivePersonCertReturnsNullWhenNeverCertified() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + + assertNull(service.getEffectivePersonCert()); + } + + @Test + @DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)") + void resubmitKeepsEffectiveValue() { + Long effectiveId = 777L; + Driver driver = driver(PLATE); + driver.setEffectiveCertRecordId(effectiveId); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest oldContent = new PersonCertSubmitRequest(); + oldContent.setRealName("旧的已通过内容"); + TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED); + effective.setSnapshotJson(JSON.toJSONString(oldContent)); + when(certRecordService.getById(effectiveId)).thenReturn(effective); + + // 提交新内容 + PersonCertSubmitRequest newContent = new PersonCertSubmitRequest(); + newContent.setRealName("新提交待审内容"); + service.submitPersonCert(newContent); + + // 生效值不受影响 + PersonCertSubmitRequest stillEffective = service.getEffectivePersonCert(); + assertEquals("旧的已通过内容", stillEffective.getRealName()); + assertNotEquals("新提交待审内容", stillEffective.getRealName()); + } + + @Test + @DisplayName("记录列表:类型非法必须拒绝") + void listRecordsRejectsInvalidBizType() { + assertThrows(ServiceException.class, () -> service.listRecords("truck", 1L)); + assertThrows(ServiceException.class, () -> service.listRecords(null, 1L)); + } + + @Test + @DisplayName("记录列表:本人司机记录按传入条件查询(走统一查询入口)") + void listRecordsForSelf() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID)) + .thenReturn(List.of()); + + assertTrue(service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID).isEmpty()); + verify(certRecordService, times(1)) + .selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID); + } + + // ---------------------------------------------------------------- 删除车辆认证 + + @Test + @DisplayName("删除车辆认证:逻辑删除车辆,且**不删**认证记录") + void deleteVehicleCertIsLogicalOnly() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L)); + + service.deleteVehicleCert(VEHICLE_ID); + + // 走的是 deleteLogic(is_deleted=1),不是物理删除 + verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID)); + verify(transportVehicleService, never()).removeById(anyLong()); + // 认证记录是历史依据,任何情况下都不得被删 + verify(certRecordService, never()).removeById(anyLong()); + verify(certRecordService, never()).removeByIds(any()); + } + + @Test + @DisplayName("删除车辆认证:越权必须拒绝") + void deleteVehicleCertRejectsUnbound() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)) + .thenReturn(vehicle(VEHICLE_ID, "桂B99999", null)); + + assertThrows(ServiceException.class, () -> service.deleteVehicleCert(VEHICLE_ID)); + verify(transportVehicleService, never()).deleteLogic(any()); + } + + @Test + @DisplayName("删除车辆认证:车牌写法差异不影响绑定判定") + void deleteVehicleCertAcceptsMessyPlate() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523")); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null)); + + service.deleteVehicleCert(VEHICLE_ID); + verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID)); + } + + // ---------------------------------------------------------------- 新增车辆 + + @Test + @DisplayName("新增车辆:车牌未登记时就地建档并绑定到本人,且新车不被视为已认证") + void submitVehicleCreatesUnregisteredVehicle() { + Driver driver = driver(null); // 司机名下还没有任何车 + when(driverAppService.currentDriverEntity()).thenReturn(driver); + // 全库都查不到这个车牌 + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂A·32523"); + + service.submitVehicleCert(request); + + // 建档:保存了一辆车,车挂牌照为规范化结果,且状态为启用 + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).save(vehicleCaptor.capture()); + assertEquals("桂A32523", vehicleCaptor.getValue().getPlateNo()); + assertEquals("重型货车", vehicleCaptor.getValue().getVehicleType()); + assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(), + "新建的车不得自带生效快照——是否已认证只能由审核通过决定"); + assertEquals("桂物物流集团", vehicleCaptor.getValue().getOrganizationName(), + "所属组织必须从司机档案带出:该列 NOT NULL 且无默认值,空着这条 INSERT 直接报错"); + assertEquals("管理挂靠", vehicleCaptor.getValue().getBusinessRelation(), + "业务关系由司机在表单里选,服务端照实落库、不替用户猜"); + assertNotNull(vehicleCaptor.getValue().getRegistrationNo(), + "机动车登记编号该列 NOT NULL:司机端选填,缺值要写空串而不是 null"); + assertEquals("桂物物流集团", vehicleCaptor.getValue().getOwnerName(), + "所有人是行驶证上的登记主体,与所属组织是两个字段"); + + // 绑定:司机的 driving_vehicle 被追加该车牌 + ArgumentCaptor driverCaptor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(driverCaptor.capture()); + assertEquals("桂A32523", driverCaptor.getValue().getDrivingVehicle()); + + // 仍然产生一条待审记录 + verify(certRecordService).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("新增车辆:车牌已登记在别人名下必须拒绝,不能顺手改归属") + void submitVehicleRejectsPlateOwnedByAnotherDriver() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂B11111")); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(vehicle(VEHICLE_ID, "桂A32523", null))); // 已登记,但不在本人名下 + + VehicleCertSubmitRequest request = completeVehicleRequest("桂A32523"); + + assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("新增车辆:已登记且属于本人时直接复用,不重复建档") + void submitVehicleReusesOwnRegisteredVehicle() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523")); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(vehicle(VEHICLE_ID, "桂A·32523", null))); // 写法不同但同一辆 + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523"); + + service.submitVehicleCert(request); + + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(captor.capture()); + assertEquals(VEHICLE_ID, captor.getValue().getBizId(), "应挂到既有车辆上"); + } + + @Test + @DisplayName("新增车辆:车牌为空必须拒绝") + void submitVehicleRejectsBlankPlate() { + when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523")); + + // 内容给全,确保被拒的原因是车牌为空而不是别的字段(否则这条用例形同虚设) + VehicleCertSubmitRequest request = completeVehicleRequest(" "); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertTrue(exception.getMessage().contains("车牌"), exception.getMessage()); + } + + // ---------------------------------------------------------------- 辅助 + + @Test + @DisplayName("重新认证同样要过内容校验:只校验「新建车辆」等于给重新认证留了后门") + void submitVehicleValidatesContentOnResubmit() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + request.setOwnerName(""); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertEquals("所有人不能为空", exception.getMessage()); + verify(certRecordService, never()).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("重新认证不要求司机档案有所属组织:那一列只在新建车辆时写,与管理归属无关") + void submitVehicleResubmitDoesNotRequireOrganization() { + Driver driver = driver(PLATE); + driver.setOrganizationName(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setId(VEHICLE_ID); + + service.submitVehicleCert(request); + + verify(certRecordService).save(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("司机建车:字段不齐时给可读提示,而不是让数据库报 NOT NULL") + void submitVehicleRejectsIncompleteRequest() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest("桂A32523"); + request.setApprovedLoadKg(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertEquals("核定载质量不能为空", exception.getMessage()); + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + } + + @Test + @DisplayName("司机建车:没有所属组织时明确拒绝,不落一辆查不到归属的车") + void submitVehicleRejectsDriverWithoutOrganization() { + Driver driver = driver(null); + driver.setOrganizationName(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(completeVehicleRequest("桂A32523"))); + assertTrue(exception.getMessage().contains("所属组织")); + } + + @Test + @DisplayName("有效期只填止期:起期为空也允许提交(司机端不采集起期)") + void submitVehicleAcceptsBlankStartDate() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setDrivingLicenseStartDate(null); + request.setRoadTransportCertStartDate(null); + + service.submitVehicleCert(request); + + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).save(vehicleCaptor.capture()); + assertNotNull(vehicleCaptor.getValue().getDrivingLicenseEndDate(), "止期照常写入车辆行"); + assertNull(vehicleCaptor.getValue().getDrivingLicenseStartDate(), "起期为空就是空,不该编一个值出来"); + } + + @Test + @DisplayName("有效期未勾长期有效时止期必填:起期可以空,止期不能") + void submitVehicleRejectsBlankEndDate() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setDrivingLicenseStartDate(null); + request.setDrivingLicenseEndDate(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submitVehicleCert(request)); + assertEquals("请选择行驶证有效期", exception.getMessage()); + verify(transportVehicleService, never()).save(any(TransportVehicle.class)); + } + + @Test + @DisplayName("长期有效时止期可以为空,此时不再比较起止先后") + void submitVehicleAllowsLongTermWithoutEndDate() { + Driver driver = driver(null); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); + request.setDrivingLicenseStartDate(LocalDate.of(2020, 1, 1)); + request.setDrivingLicenseEndDate(null); + request.setDrivingLicenseLongTerm(1); + + service.submitVehicleCert(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(captor.capture()); + assertTrue(captor.getValue().getSnapshotJson().contains("\"drivingLicenseLongTerm\":1"), + "长期有效标志要落进快照"); + } + + /** + * 一份字段齐全的车辆认证提交内容 + *

+ * 车辆表有十个 NOT NULL 且无默认值的列,少一个就会触发数据库错误而不是业务提示, + * 故用例统一从这里取请求,避免每个用例各自拼一半字段。 + */ + private VehicleCertSubmitRequest completeVehicleRequest(String plateNo) { + VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + request.setPlateNo(plateNo); + request.setVehicleType("重型货车"); + request.setOwnerName("桂物物流集团"); + request.setPlateColor("黄牌"); + request.setEnergyType("柴油"); + request.setBusinessRelation("管理挂靠"); + request.setApprovedLoadKg(18000); + request.setTractionMassKg(35000); + request.setOuterLength(5990); + request.setOuterWidth(2300); + request.setOuterHeight(3100); + request.setDrivingLicenseNo("450100123456"); + request.setDrivingLicenseImage("http://oss.example.com/xsz-front.jpg"); + request.setDrivingLicenseStartDate(LocalDate.of(2018, 3, 9)); + request.setDrivingLicenseEndDate(LocalDate.of(2028, 3, 9)); + request.setRoadTransportCertNo("桂交运管0001"); + request.setRoadTransportCertStartDate(LocalDate.of(2020, 1, 1)); + request.setRoadTransportCertEndDate(LocalDate.of(2030, 1, 1)); + request.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1)); + return request; + } + + private Driver driver(String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + // 司机档案ID与用户ID是两个不同的ID:提交人字段要的是后者 + driver.setUserId(USER_ID); + driver.setDriverName("张三"); + // 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败, + // 故建车链路要求它非空,fixture 也必须给 + driver.setOrganizationName("桂物物流集团"); + driver.setDrivingVehicle(drivingVehicle); + return driver; + } + + private TransportVehicle vehicle(Long id, String plateNo, Long effectiveId) { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setId(id); + vehicle.setPlateNo(plateNo); + vehicle.setEffectiveCertRecordId(effectiveId); + return vehicle; + } + + private TransportCertRecord record(Long id, Integer status) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(id); + record.setStatus(status); + record.setSnapshotJson("{}"); + return record; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java new file mode 100644 index 00000000..86b9a3e7 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -0,0 +1,339 @@ +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.core.tool.jackson.JsonUtil; +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.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverPunchNodeVO; +import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.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; + +/** + * 节点打卡照片的类型与张数口径。 + *

+ * 新版小程序按类型提交照片,类型由提交方给出;旧版仍提交裸 URL 列表,类型只能按下标回推凭证类型。 + * 两者并存期间,新版每个类型至少 1 张(含固定的「打卡照片」)、每类各自至多 9 张; + * 旧版必须照旧提交成功——用「每个类型至少 1 张」去拦旧版,等于让未升级的小程序一次性失效, + * 而旧版根本表达不出「打卡照片」。 + */ +class DriverNodePunchPhotoTest { + + private static final Long WAYBILL_ID = 5601L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; + private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg"; + private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg"; + /** 该节点勾了两类凭证,故照片类型取值域是「打卡照片 + 磅单 + 签收单」 */ + private static final String PROCESS_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true, + "location":false,"uploadVoucher":true,"voucherTypes":["磅单","签收单"]}] + """; + + private IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + 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); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + ExceptionDisposalMapper 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); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.getOne(any())).thenReturn(waybill()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.updateById(any())).thenReturn(true); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + // ------------------------------------------------------------ 新版:显式类型 + + @Test + @DisplayName("显式类型按提交原样落库,「打卡照片」不会被改写成凭证类型") + void typedPhotoItemsKeepExplicitType() { + submit(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO)))); + + List> items = savedPhotoItems(); + assertEquals(List.of("打卡照片", "磅单", "签收单"), typesOf(items)); + assertEquals(List.of(CLOCK_PHOTO, WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items)); + } + + @Test + @DisplayName("域外类型被拒且不落库") + void typeOutsideNodeDomainIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO), + photo("卸货照片", "https://oss/unload.jpg")))); + + assertTrue(exception.getMessage().contains("卸货照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("缺少某个必填类型时被拒:每个类型至少 1 张") + void missingVoucherTypeIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO)))); + + assertTrue(exception.getMessage().contains("签收单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("缺少「打卡照片」时被拒:它不由过程配置决定,每个打卡节点都必须有") + void missingClockPhotoIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO)))); + + assertTrue(exception.getMessage().contains("打卡照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("同一类型第 9 张可提交") + void ninthPhotoOfSameTypeIsAccepted() { + submit(nodeSubmit(typedPhotos("磅单", 9))); + + assertEquals(9, countByType(savedPhotoItems(), "磅单")); + } + + @Test + @DisplayName("同一类型第 10 张被拒且不落库:上限按类型各自计数") + void tenthPhotoOfSameTypeIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(typedPhotos("磅单", 10))); + + assertTrue(exception.getMessage().contains("磅单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 回显 + + @Test + @DisplayName("回显按类型原样透传:「打卡照片」不会被丢弃,也不会被改写成凭证类型") + void punchNodeDisplayKeepsEveryType() { + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(storedPunch())); + + DriverPunchNodeVO node = service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream() + .filter(item -> "load".equals(item.getKey())) + .findFirst() + .orElseThrow(); + + assertEquals(List.of("打卡照片", "磅单"), + node.getPhotos().stream().map(DriverPunchPhotoVO::getType).toList()); + } + + // ------------------------------------------------------------ 旧版:裸 URL 列表 + + @Test + @DisplayName("旧版裸 URL 列表仍能提交成功,类型按下标回推凭证类型") + void legacyPhotoUrlsStillSubmit() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotos(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO)); + + submit(dto); + + List> items = savedPhotoItems(); + assertEquals(List.of("磅单", "签收单"), typesOf(items)); + assertEquals(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items)); + } + + @Test + @DisplayName("旧版不带「打卡照片」也放行:裸 URL 列表表达不出这个类型") + void legacyPhotoUrlsWithoutClockPhotoStillSubmit() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotos(List.of(WAYBILL_PHOTO)); + + submit(dto); + + assertEquals(List.of("磅单"), typesOf(savedPhotoItems())); + } + + @Test + @DisplayName("旧版与新版都没带照片时被拒") + void withoutAnyPhotoIsRejected() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + + ServiceException exception = submitExpectRejected(dto); + + assertTrue(exception.getMessage().contains("照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 夹具 + + private void submit(NodeSubmitDTO dto) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + service.submitNode(dto); + } + } + + private ServiceException submitExpectRejected(NodeSubmitDTO dto) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return assertThrows(ServiceException.class, () -> service.submitNode(dto)); + } + } + + /** 已入库的打卡流水;断言只在这里取一次插入参数 */ + private WaybillNodePunch savedPunch() { + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + return captor.getValue(); + } + + @SuppressWarnings("unchecked") + private List> savedPhotoItems() { + return (List>) JsonUtil.parse(savedPunch().getPhotos(), List.class); + } + + private List typesOf(List> items) { + return items.stream().map(item -> item.get("type")).toList(); + } + + private List urlsOf(List> items) { + return items.stream().map(item -> item.get("url")).toList(); + } + + private int countByType(List> items, String type) { + return (int) items.stream().filter(item -> type.equals(item.get("type"))).count(); + } + + /** 每个必填类型各 1 张,另有 count 张「磅单」 */ + private List typedPhotos(String type, int count) { + List items = new ArrayList<>(); + items.add(photo("打卡照片", CLOCK_PHOTO)); + items.add(photo("签收单", RECEIPT_PHOTO)); + for (int i = 0; i < count; i++) { + items.add(photo(type, "https://oss/" + type + i + ".jpg")); + } + return items; + } + + private NodeSubmitDTO nodeSubmit(List photoItems) { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotoItems(photoItems); + return dto; + } + + private PunchPhotoDTO photo(String type, String url) { + PunchPhotoDTO photo = new PunchPhotoDTO(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setVehicleNo(PLATE); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + + /** 库里的打卡流水:一条带「打卡照片」与「磅单」的回显数据 */ + private WaybillNodePunch storedPunch() { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6601L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode("load"); + punch.setNodeName("装货"); + punch.setPunchTime(new Date()); + punch.setPhotos(JsonUtil.toJson(List.of( + Map.of("type", "打卡照片", "url", CLOCK_PHOTO), + Map.of("type", "磅单", "url", WAYBILL_PHOTO)))); + return punch; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java new file mode 100644 index 00000000..ed1e5495 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -0,0 +1,234 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.springblade.core.tool.jackson.JsonUtil; +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.ExceptionDisposal; +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.DriverPunchNodeVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +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.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机端节点卡片上的异常标签与「相关异常」列表。 + *

+ * 两个字段回答两个不同的问题:这个节点上挂着哪些未了结异常、这张运单有没有整单异常。 + * 整单异常不归属任何节点,所以每个可打卡节点都挂它——包括已打卡的与在途的; + * 而某条节点异常只算它自己那个节点,被它牵连阻断的后续节点不算相关。 + */ +class DriverPunchNodeCardTest { + + private static final Long WAYBILL_ID = 5501L; + + /** 接单不参与打卡,故不出现在节点卡片里 */ + private static final String PROCESS_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private IWaybillService waybillService; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + 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, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper); + + Waybill waybill = waybill(); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch("load"))); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("整单异常挂到每个可打卡节点,含已打卡的与在途的") + void waybillLevelExceptionIsTaggedOnEveryNode() { + stubUnresolved(nodeException("load", 11L), waybillException(12L)); + + Map nodes = punchNodes(); + + assertEquals(3, nodes.size(), "接单不是打卡节点,不该出现在卡片里"); + assertTrue(Boolean.TRUE.equals(nodes.get("load").getDone()), "装货这条流水已打卡"); + for (DriverPunchNodeVO node : nodes.values()) { + assertTrue(Boolean.TRUE.equals(node.getHasWaybillException()), + "整单异常作用于整张运单," + node.getKey() + " 也该挂标签"); + assertTrue(node.getRelatedExceptionIds().contains("12"), + node.getKey() + " 的相关异常里应当有整单异常"); + } + } + + @Test + @DisplayName("节点自己的异常只进它自己的相关列表,不牵连后续节点") + void nodeExceptionOnlyBelongsToItsOwnNode() { + stubUnresolved(nodeException("load", 11L)); + + Map nodes = punchNodes(); + + assertTrue(Boolean.TRUE.equals(nodes.get("load").getHasException())); + assertEquals(List.of("11"), nodes.get("load").getRelatedExceptionIds()); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasException()), + "签收只是被装货的异常牵连阻断,不挂「异常」标签"); + assertTrue(nodes.get("sign").getRelatedExceptionIds().isEmpty()); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasWaybillException())); + } + + @Test + @DisplayName("两种异常同时存在时,该节点的相关列表有两条且按上报时间升序") + void bothKindsAreListedInReportOrder() { + stubUnresolved(nodeException("load", 11L), waybillException(12L)); + + DriverPunchNodeVO load = punchNodes().get("load"); + + assertEquals(List.of("11", "12"), load.getRelatedExceptionIds()); + } + + @Test + @DisplayName("异常全部了结后两个字段都回到「无」") + void withoutUnresolvedExceptionsNoTagAndNoRelatedIds() { + Map nodes = punchNodes(); + + for (DriverPunchNodeVO node : nodes.values()) { + assertFalse(Boolean.TRUE.equals(node.getHasWaybillException())); + assertTrue(node.getRelatedExceptionIds().isEmpty()); + } + } + + @Test + @DisplayName("未了结异常按上报时间升序取出,卡片顺序才有意义") + void unresolvedExceptionsAreReadInReportOrder() { + stubUnresolved(nodeException("load", 11L)); + + punchNodes(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(exceptionDisposalMapper, atLeastOnce()).selectList(captor.capture()); + for (LambdaQueryWrapper wrapper : captor.getAllValues()) { + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("report_time"), "未了结异常要按上报时间取:" + sql); + assertTrue(sql.contains("disposal_status"), "未了结的判据要落在处置状态上:" + sql); + } + } + + @Test + @DisplayName("相关异常 id 序列化成字符串,前端拿到大整数不会丢精度") + void relatedExceptionIdsSerializeAsStrings() { + stubUnresolved(nodeException("load", 9007199254740993L)); + + String json = JsonUtil.toJson(punchNodes().get("load")); + + assertTrue(json.contains("\"9007199254740993\""), json); + } + + // ------------------------------------------------------------ 夹具 + + private void stubUnresolved(ExceptionDisposal... disposals) { + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(disposals)); + } + + private Map punchNodes() { + return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream() + .collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity())); + } + + private ExceptionDisposal nodeException(String nodeCode, Long id) { + return exception(id, nodeCode, LocalDateTime.of(2026, 10, 6, 9, 30)); + } + + private ExceptionDisposal waybillException(Long id) { + return exception(id, null, LocalDateTime.of(2026, 10, 6, 10, 0)); + } + + private ExceptionDisposal exception(Long id, String nodeCode, LocalDateTime reportTime) { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(id); + disposal.setWaybillId(WAYBILL_ID); + disposal.setNodeCode(nodeCode); + disposal.setDisposalStatus("pending"); + disposal.setIsDeleted(0); + disposal.setReportTime(reportTime); + return disposal; + } + + private WaybillNodePunch nodePunch(String nodeCode) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6501L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + punch.setPunchTime(new Date()); + return punch; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java new file mode 100644 index 00000000..1aef3f7c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -0,0 +1,250 @@ +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.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.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverNodePunchVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +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.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; + +/** + * 司机打完末位打卡节点后运单自动完成。 + *

+ * 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、 + * 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水, + * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。 + */ +class DriverWaybillAutoCompleteTest { + + private static final Long WAYBILL_ID = 5801L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; + + /** 末位打卡节点是「签收」,没有回单节点 */ + private static final String SIGN_LAST_JSON = """ + [{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + /** 末位打卡节点是「签收」,回单启用且需确认完成 */ + private static final String SIGN_LAST_RETURN_CONFIRM_JSON = """ + [{"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 IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + 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); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.updateById(any())).thenReturn(true); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 命中 + + @Test + @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted") + void lastDeliveryPunchCompletesWaybill() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成"); + } + + @Test + @DisplayName("waybillCompleted 取完成接口的返回值,不按判定命中臆断") + void completionFlagFollowsServiceReturn() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(false); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成"); + } + + // ------------------------------------------------------------ 不命中 + + @Test + @DisplayName("末位是发货时只打卡不完成") + void punchOnNonDeliveryLastNodeDoesNotComplete() { + givenWaybill(DISPATCH_LAST_JSON, "running"); + + DriverNodePunchVO vo = submit("dispatch"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("回单启用且需确认完成时只打卡不完成") + void returnNeedingConfirmationDoesNotComplete() { + 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()); + } + + // ------------------------------------------------------------ 前置:运单本身能否完成 + + @Test + @DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功") + void draftWaybillIsPunchedButNotCompleted() { + givenWaybill(SIGN_LAST_JSON, "draft"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("待执行与调度中间态同样允许自动完成") + void dispatchableStatusesStillComplete() { + for (String status : List.of("pending", "waiting_dispatch", "dispatching")) { + givenWaybill(SIGN_LAST_JSON, status); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态"); + } + } + + @Test + @DisplayName("有未了结异常时不自动完成——与司机端「完成运单」同一口径,异常不落在打卡节点上也拦") + void unresolvedExceptionBlocksAutoComplete() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload"))); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + // ------------------------------------------------------------ 夹具 + + private DriverNodePunchVO submit(String nodeCode) { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode(nodeCode); + dto.setPhotoItems(List.of(photo("打卡照片", CLOCK_PHOTO))); + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return service.submitNode(dto); + } + } + + private void givenWaybill(String processJson, String businessStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610080001"); + waybill.setVehicleNo(PLATE); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setIsDeleted(0); + when(waybillService.getOne(any())).thenReturn(waybill); + } + + private PunchPhotoDTO photo(String type, String url) { + PunchPhotoDTO photo = new PunchPhotoDTO(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private ExceptionDisposal unresolvedException(String nodeCode) { + ExceptionDisposal exception = new ExceptionDisposal(); + exception.setNodeCode(nodeCode); + exception.setDisposalStatus("processing"); + return exception; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java new file mode 100644 index 00000000..86033d39 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -0,0 +1,138 @@ +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.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.ExceptionDisposal; +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.DriverWaybillCardVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +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; + +/** + * 司机端卡片上的发货地 / 到货地。 + *

+ * 地点名称与详细地址是两列:批量导入的运单只填了地址(导入模板里没有「发货地」「收货地」列), + * 名称为空时卡片要回退到地址,否则司机端的地点位置是空的。 + */ +class DriverWaybillCardLocationTest { + + private static final Long WAYBILL_ID = 5601L; + + private static final String PROCESS_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private static final String DEPARTURE_ADDRESS = "广西壮族自治区南宁市西乡塘区安吉大道"; + private static final String ARRIVAL_ADDRESS = "广西壮族自治区河池市金城江区金城中路1号"; + + private IWaybillService waybillService; + 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, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + WaybillNodePunchMapper nodePunchMapper = mock(WaybillNodePunchMapper.class); + WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper); + + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("地点名称为空(导入的运单)时,名称回退到详细地址") + void nameFallsBackToAddressWhenBlank() { + Waybill waybill = waybill(); + waybill.setDepartureName(null); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName(""); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals(DEPARTURE_ADDRESS, card.getFromName()); + assertEquals(ARRIVAL_ADDRESS, card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + assertEquals(DEPARTURE_ADDRESS, card.getPickupAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getUnloadAddress()); + } + + @Test + @DisplayName("地点名称有值时用它,不被地址覆盖") + void nameWinsWhenPresent() { + Waybill waybill = waybill(); + waybill.setDepartureName("南宁市西乡塘区"); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName("河池市金城江区"); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals("南宁市西乡塘区", card.getFromName()); + assertEquals("河池市金城江区", card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD-20261007-0006"); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java new file mode 100644 index 00000000..483ed637 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -0,0 +1,113 @@ +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.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.WaybillNodePunch; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 节点是否已打卡的判定。 + *

+ * 它读的是打卡流水表,而不是运单的当前节点:管理端取消打卡会把流水逻辑删除, + * 那时这里必须立刻回到未打卡,否则已取消的打卡仍会挡住异常上报。 + * 节点编码按忽略大小写比对——流水里的写法与过程配置的 key 不保证一致。 + */ +class DriverWaybillNodePunchTest { + + private static final Long WAYBILL_ID = 5201L; + + private WaybillNodePunchMapper nodePunchMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + } + + @BeforeEach + void setUp() { + nodePunchMapper = mock(WaybillNodePunchMapper.class); + service = new DriverWaybillServiceImpl( + mock(IWaybillService.class), + mock(IDriverService.class), + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + mock(ExceptionDisposalMapper.class)); + } + + @Test + @DisplayName("没有该节点的打卡流水就是未打卡") + void withoutPunchRecordNodeIsNotPunched() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + assertFalse(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("有该节点的打卡流水即已打卡") + void punchRecordMakesNodePunched() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("load"))); + + assertTrue(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("别的节点的打卡流水不算本节点已打卡") + void otherNodePunchDoesNotCount() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("arrive_scene"))); + + assertFalse(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("流水里的节点编码大小写与查询不一致时仍算同一节点") + void nodeCodeComparisonIgnoresCase() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("LOAD"))); + + assertTrue(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("运单或节点缺失时判为未打卡,不抛异常") + void missingArgumentsMeanNotPunched() { + assertFalse(service.isNodePunched(null, "load")); + assertFalse(service.isNodePunched(WAYBILL_ID, " ")); + } + + private WaybillNodePunch punch(String nodeCode) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6201L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + return punch; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java new file mode 100644 index 00000000..035a2db1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -0,0 +1,171 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.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.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.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; + +/** + * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 + *

+ * 口径:当前用户手机号匹配运单 {@code driverPhone}。 + * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 + */ +class DriverWaybillOwnershipTest { + + private static final Long WAYBILL_ID = 5301L; + private static final Long USER_ID = 7001L; + private static final String PHONE = "13800138000"; + + private IDriverService driverService; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + } + + @Test + @DisplayName("登录账号没有司机档案时被拒:管理端与调度员不提供代报") + void withoutDriverProfileIsRejected() { + when(driverService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("未找到当前登录司机档案", exception.getMessage()); + verify(waybillService, never()).getOne(any()); + } + + @Test + @DisplayName("司机档案与登录态都没有手机号时被拒") + void withoutPhoneIsRejected() { + Driver driver = driver(null); + when(driverService.getOne(any())).thenReturn(driver); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage()); + } + + @Test + @DisplayName("运单司机手机号与当前用户不一致时被拒") + void waybillOfAnotherPhoneIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("本人手机号运单放行,且查询确实按司机手机号收敛") + void ownWaybillPasses() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); + when(waybillService.getOne(any())).thenReturn(waybill()); + + assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).getOne(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + assertTrue(wrapper.getTargetSql().contains("driver_phone"), + "归属查询必须落在运单司机手机号上:" + wrapper.getTargetSql()); + assertTrue(wrapper.getParamNameValuePairs().values().stream() + .anyMatch(PHONE::equals), + "归属查询必须带上当前用户手机号:" + wrapper.getParamNameValuePairs()); + } + + @Test + @DisplayName("运单ID为空时被拒") + void withoutWaybillIdIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(null)); + + assertEquals("运单ID不能为空", exception.getMessage()); + } + + // ------------------------------------------------------------ 夹具 + + private void runOwnershipCheck(Long waybillId) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUserAccount).thenReturn(PHONE); + service.assertCurrentDriverOwnsWaybill(waybillId); + } + } + + private Driver driver(String mobile) { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDriverName("韦司机"); + driver.setMobile(mobile); + return driver; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setDriverPhone(PHONE); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java new file mode 100644 index 00000000..3553c9c3 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -0,0 +1,232 @@ +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.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.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.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +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.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机拒单后运单对本人脱离。 + *

+ * 拒单不再只是一个标记:该运单从列表、计数、详情到接单全部不可达,等管理员重新派单。 + *

+ * 拦截落在司机端共用的取单条件上,而单元测试里 {@code waybillService} 是 mock、不会真的执行 + * 这个条件,所以「已拒单被排除」只能断言条件本身;过滤之后的行为则用「已过滤的输入」来验, + * 不假装 mock 会替我们过滤。 + */ +class DriverWaybillRejectDetachTest { + + private static final Long USER_ID = 7401L; + private static final Long DRIVER_ID = 9001L; + private static final String PLATE = "沪AE31312"; + private static final Long REJECTED_ID = 7201L; + + private IDriverService driverService; + private CertificationGate certificationGate; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + certificationGate = mock(CertificationGate.class); + lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + certificationGate, + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + lenient().when(driverService.getOne(any())).thenReturn(driver()); + lenient().when(waybillService.updateById(any())).thenReturn(true); + // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; + // 真实实现是把入参校正后返回同一个对象 + lenient().when(waybillService.syncDriverAcceptState(any())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列") + void pendingPreviewQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.pendingPreview(5)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), + "取单条件必须排除已拒单:" + sql); + assertTrue(sql.toUpperCase().contains("AND"), + "排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql); + } + + @Test + @DisplayName("运单列表的取单条件同样排除已拒单") + void pageQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.page(1, 10, 0, null)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), "列表也要排除已拒单:" + sql); + } + + @Test + @DisplayName("Tab 计数不再把已拒单算进待接单") + void tabCountsOnFilteredInputCountsOnlyUnrejected() { + // 查询层已排除拒单后落回来的输入:一张待接、一张运输中、一张已完成 + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"), + waybill(7204L, "YD-20261001-0004", "completed", "accepted"))); + + DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tabCounts()); + + assertEquals(1, counts.getPending(), "待接单只算那张未被拒的"); + assertEquals(1, counts.getDoing()); + assertEquals(1, counts.getDone()); + assertEquals(3, counts.getAll(), "全部 = 待接 + 进行中 + 已完成"); + } + + @Test + @DisplayName("未拒单的运单不被误伤:待接与运输中照常出现") + void unrejectedWaybillsUnaffected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"))); + + IPage page = runAsDriver(() -> service.page(1, 10, null, null)); + + assertEquals(2, page.getTotal(), "未被拒的运单条数不变"); + assertEquals(2, page.getRecords().size()); + } + + @Test + @DisplayName("已拒单运单的详情不可达:取单条件已把它挡住") + void detailOfRejectedIsUnreachable() { + // 被取单条件排除后 getOne 落回 null,与「不属于这个司机」同一条报错 + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.detail(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("已拒单运单不能自行接回来") + void acceptOfRejectedIsRejected() { + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.accept(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("在途打卡保留原取单判据,不排除已拒单的历史脏数据") + void enrouteSubmitKeepsUnfilteredScope() { + EnrouteSubmitDTO dto = new EnrouteSubmitDTO(); + dto.setWaybillId(REJECTED_ID); + when(waybillService.getOne(any())).thenReturn(rejectedWaybill()); + + // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, + // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.submitEnroute(dto))); + + assertEquals("该运单未启用在途打卡", exception.getMessage(), + "在途打卡不应因运单已拒单而在取单阶段被挡下"); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).getOne(any()); + } + + // ------------------------------------------------------------ 夹具 + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return action.get(); + } + } + + private LambdaQueryWrapper capturedListQuery() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).list(captor.capture()); + return captor.getValue(); + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill rejectedWaybill() { + return waybill(REJECTED_ID, "YD-20261010-0001", "pending", "rejected"); + } + + private Waybill waybill(Long id, String no, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(no); + waybill.setVehicleNo(PLATE); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java new file mode 100644 index 00000000..9d0dd4a1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -0,0 +1,115 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.mockito.ArgumentCaptor; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +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 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.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常处置列表按运单过滤。 + *

+ * 过滤必须落在运单主键上:运单号是模糊匹配,{@code YD001} 会命中 {@code YD0011}, + * 从某个节点点进来的「本运单异常」会多出别人的记录。 + */ +class ExceptionDisposalListQueryTest { + + private static final Long WAYBILL_ID = 5401L; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + 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); + service = new ExceptionDisposalServiceImpl( + mock(ExceptionDisposalFollowRecordMapper.class), + mock(IWaybillService.class), + mock(IDriverWaybillService.class)); + inject("baseMapper", disposalMapper); + when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); + } + + @Test + @DisplayName("传运单ID时按主键精确过滤,而不是拿运单号模糊匹配") + void waybillIdFilterIsExact() { + ExceptionDisposalVO query = new ExceptionDisposalVO(); + query.setWaybillId(WAYBILL_ID); + + LambdaQueryWrapper wrapper = capturedQuery(query); + + assertTrue(wrapper.getTargetSql().contains("waybill_id ="), + "按运单过滤要用等值:" + wrapper.getTargetSql()); + assertTrue(wrapper.getParamNameValuePairs().values().stream() + .anyMatch(value -> WAYBILL_ID.equals(value) + || (value instanceof Collection values && values.contains(WAYBILL_ID))), + "过滤条件要带上运单ID:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getTargetSql().contains("waybill_no"), + "运单号是 like,会把前缀相同的运单一起捞回来:" + wrapper.getTargetSql()); + } + + @Test + @DisplayName("不传运单ID时不加运单条件,列表原有入口行为不变") + void withoutWaybillIdNoWaybillCondition() { + LambdaQueryWrapper wrapper = capturedQuery(new ExceptionDisposalVO()); + + assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql()); + } + + // ------------------------------------------------------------ 夹具 + + private LambdaQueryWrapper capturedQuery(ExceptionDisposalVO query) { + IPage page = service.selectPage(new Page<>(1, 10), query); + assertTrue(page.getRecords().isEmpty()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectPage(any(), captor.capture()); + return captor.getValue(); + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java new file mode 100644 index 00000000..be77a57c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -0,0 +1,379 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.pojo.entity.ExceptionDisposal; +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.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常上报门禁:已打卡的节点、已有未了结异常的节点都不能再上报。 + *

+ * 这里的口径决定「这个节点还能不能再报一条异常」,与打卡门禁是两回事: + * 一个管还能不能报,一个管还能不能打卡。两者共用同一份未了结判定, + * 但解除条件互不相同,所以断言分开下。 + */ +class ExceptionDisposalReportGateTest { + + private static final Long WAYBILL_ID = 5101L; + private static final Long USER_ID = 7001L; + + private ExceptionDisposalMapper disposalMapper; + private IWaybillService waybillService; + private IDriverWaybillService driverWaybillService; + 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); + waybillService = mock(IWaybillService.class); + driverWaybillService = mock(IDriverWaybillService.class); + service = new ExceptionDisposalServiceImpl( + mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService); + inject("baseMapper", disposalMapper); + + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); + when(disposalMapper.selectCount(any())).thenReturn(0L); + when(disposalMapper.insert(any(ExceptionDisposal.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 已打卡 + + @Test + @DisplayName("已打卡的普通节点不能再上报异常") + void punchedNodeRejectsReport() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已打卡,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("在途已打卡仍可上报:它可重复打卡、没有完成含义,打卡与否不说明环节已经过去") + void punchedTransitNodeStillAcceptsReport() { + stubNode("transit", "在途", "transit"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "transit")).thenReturn(true); + + submitOk("transit"); + + assertEquals("transit", savedDisposal().getNodeCode()); + } + + @Test + @DisplayName("未打卡的普通节点照常可上报") + void unpunchedNodeAcceptsReport() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + + submitOk("load"); + + assertEquals("load", savedDisposal().getNodeCode()); + assertEquals("装货", savedDisposal().getNodeName()); + } + + // ------------------------------------------------------------ 已有未了结异常 + + @Test + @DisplayName("节点已有待处理异常时拒绝再报") + void pendingNodeExceptionRejectsReport() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(1L); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + } + + @Test + @DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁") + void processingNodeExceptionRejectsReport() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(1L); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + List params = capturedUnresolvedParams(); + assertTrue(params.contains("completed") && params.contains("revoked"), + "未了结 = 处置状态不在这两个终态里,故「处理中」也占位:" + params); + } + + @Test + @DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报") + void settledNodeExceptionAllowsReportAgain() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(0L); + + submitOk("load"); + + assertEquals("load", savedDisposal().getNodeCode()); + assertTrue(capturedUnresolvedParams().containsAll(List.of("completed", "revoked")), + "只有已完成、已撤销解除门禁,SQL 必须把它们排除在未了结之外"); + } + + @Test + @DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上") + void otherNodesExceptionDoesNotBlockReport() { + stubNode("unload", "卸货", "cargo"); + stubUnresolvedCount(0L); + + submitOk("unload"); + + assertEquals("unload", savedDisposal().getNodeCode()); + assertTrue(capturedUnresolvedParams().contains("unload"), + "节点归属条件落在 SQL 上,查的是本次上报的节点:" + capturedUnresolvedParams()); + } + + @Test + @DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点") + void nodeCodeComparisonIgnoresCase() { + stubNode("load", "装货", "cargo"); + stubUnresolvedCount(1L); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(TRIM(node_code)) ="), + "忽略大小写要落在 SQL 上:" + capturedUnresolvedQuery().getTargetSql()); + assertTrue(capturedUnresolvedParams().contains("load"), + "参数取节点 key 的小写形式:" + capturedUnresolvedParams()); + } + + @Test + @DisplayName("门禁拿过程配置里的节点 key 去比对,而不是请求体里那串写法") + void gateUsesNodeKeyResolvedFromProcessConfig() { + // 请求体写的是大写,过程配置里的 key 是小写;打卡流水按配置的 key 落库 + when(driverWaybillService.findPunchNode(WAYBILL_ID, "LOAD")).thenReturn(node("load", "装货", "cargo")); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("LOAD"); + + assertEquals("该节点已打卡,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("未了结异常的查询按运单收敛,并排除已删除的行") + void unresolvedQueryIsScopedToWaybillAndSkipsDeletedRows() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + + submitOk("load"); + + String sql = capturedUnresolvedQuery().getTargetSql(); + + assertTrue(sql.contains("waybill_id"), + "未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql); + assertTrue(sql.contains("is_deleted"), + "已删除的异常不算未了结:" + sql); + assertTrue(capturedUnresolvedParams().contains(WAYBILL_ID), + "运单条件取本次上报的运单:" + capturedUnresolvedParams()); + } + + // ------------------------------------------------------------ 整单异常不受门禁 + + @Test + @DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报") + void waybillLevelReportIsNotGated() { + stubUnresolvedCount(1L); + + submitOk(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).insert(captor.capture()); + assertNull(captor.getValue().getNodeCode(), "整单异常没有节点,不得凭空补一个"); + verify(driverWaybillService, never()).findPunchNode(any(), any()); + verify(driverWaybillService, never()).isNodePunched(any(), any()); + verify(disposalMapper, never()).selectCount(any()); + } + + @Test + @DisplayName("上报人取登录态,请求体里的上报人一律被忽略") + void reporterComesFromLoginState() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + ExceptionDisposal request = request("load"); + request.setReporterId(999999L); + request.setReporterName("冒充的人"); + + runSubmit(request); + + assertEquals(USER_ID, savedDisposal().getReporterId()); + assertEquals("韦司机", savedDisposal().getReporterName()); + } + + // ------------------------------------------------------------ 上报人归属 + + @Test + @DisplayName("上报人不属于该运单时在落库前就被拒") + void reportIsRejectedWhenReporterDoesNotOwnWaybill() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + doThrow(new ServiceException("运单不存在或无权操作")) + .when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("整单异常同样校验上报人:没有节点不等于没有上报人") + void waybillLevelReportAlsoChecksReporterOwnership() { + doThrow(new ServiceException("未找到当前登录司机档案")) + .when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID); + + ServiceException exception = submitExpectRejected(null); + + assertEquals("未找到当前登录司机档案", exception.getMessage()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubNode(String key, String name, String type) { + when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type)); + when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false); + } + + private void stubUnresolvedCount(long count) { + when(disposalMapper.selectCount(any())).thenReturn(count); + } + + /** 上报门禁查未了结异常的那条 count 查询 */ + private LambdaQueryWrapper capturedUnresolvedQuery() { + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectCount(captor.capture()); + return captor.getValue(); + } + + /** 上述查询的参数值,集合型参数摊平成元素 */ + private List capturedUnresolvedParams() { + LambdaQueryWrapper query = capturedUnresolvedQuery(); + // MyBatis-Plus 的条件参数是惰性的:取过 SQL 片段之后 paramNameValuePairs 里才有值 + query.getTargetSql(); + Collection values = query.getParamNameValuePairs().values(); + List params = new ArrayList<>(); + for (Object value : values) { + if (value instanceof Collection nested) { + params.addAll(nested); + } else { + params.add(value); + } + } + return params; + } + + private ServiceException submitExpectRejected(String nodeCode) { + ServiceException exception = assertThrows(ServiceException.class, () -> runSubmit(request(nodeCode))); + // 门禁在落库之前:被拒时库里不能多一行 + verify(disposalMapper, never()).insert(any(ExceptionDisposal.class)); + return exception; + } + + private void submitOk(String nodeCode) { + runSubmit(request(nodeCode)); + } + + private void runSubmit(ExceptionDisposal request) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class); + MockedStatic userCache = mockStatic(UserDisplayCache.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦司机"); + service.submitReport(request); + } + } + + private ExceptionDisposal savedDisposal() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).insert(captor.capture()); + return captor.getValue(); + } + + private ExceptionDisposal request(String nodeCode) { + ExceptionDisposal request = new ExceptionDisposal(); + request.setWaybillId(WAYBILL_ID); + request.setExceptionType("cargo_damage"); + request.setReportDescription("装货时发现外包装破损"); + request.setNodeCode(nodeCode); + return request; + } + + private Map node(String key, String name, String type) { + Map cfg = new HashMap<>(); + cfg.put("key", key); + cfg.put("name", name); + cfg.put("type", type); + cfg.put("enabled", true); + cfg.put("punch", true); + return cfg; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setVehicleNo("桂AAW3255"); + waybill.setIsDeleted(0); + return waybill; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java new file mode 100644 index 00000000..1d27f177 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java @@ -0,0 +1,198 @@ +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.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.ArgumentMatchers; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.AdminTodoItemVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.IWaybillService; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 调度端首页待处理事项:异常待处置 + 重新派单待处理。 + *

+ * 拒单条目的判据是「司机已拒单且运单未取消未完成」,必须与待处理运单列表的 needReassign + * 口径一致,否则两个页面的条数对不上;卡片上「多久之前」取拒单时刻,不取最后修改时间 + * (后者会被任何无关改动刷新)。 + *

+ * 这里直接验归并方法本身,不走 {@code home()}:那个入口会解析当前用户名、依赖 Spring 缓存, + * 在单测里没有上下文。 + */ +class ManageAdminHomeFeedTest { + + private static final Long EXCEPTION_ID = 8801L; + private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001"; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + private IExceptionDisposalService exceptionService; + + @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); + exceptionService = mock(IExceptionDisposalService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionService, + mock(IRiskDisposalService.class), + mock(IDriverWaybillService.class)); + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + } + + @Test + @DisplayName("拒单运单产出一条重新派单待处理,字段与跳转参数对齐小程序约定") + void rejectedWaybillAppearsAsReassignTodo() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7201L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + AdminTodoItemVO item = feed.get(0); + assertEquals("reassign", item.getType()); + assertEquals("重新派单待处理", item.getTitle()); + assertEquals("重新派单", item.getActionLabel()); + assertEquals(REJECTED_WAYBILL_NO, item.getWaybillNo()); + assertEquals(Long.valueOf(7201L), item.getId(), "重派条目应当带运单主键"); + assertEquals("运单已指派黄大富,被拒绝,请重新派单:时间冲突", item.getDesc()); + assertEquals("/pages/waybill/reassign?id=7201&buyerPaid=0", item.getTargetUrl(), + "跳转参数必须是运单主键:该页按主键回显运单,填运单号读不出数据"); + assertFalse(item.getTimeAgo().isEmpty(), "拒单时刻应换算成相对时间"); + } + + @Test + @DisplayName("拒单原因为空时正文不带空冒号") + void withoutRejectReasonKeepsDescClean() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7202L, "黄宇富", null))); + + AdminTodoItemVO item = service.buildAdminFeed().get(0); + + assertEquals("运单已指派黄宇富,被拒绝,请重新派单", item.getDesc()); + assertFalse(item.getDesc().contains(":"), "原因缺失时不得留下空的「:」"); + } + + @Test + @DisplayName("没有拒单时只出异常条目,不凭空造重派条目") + void withoutRejectedWaybillNoReassignTodo() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + assertEquals("exception", feed.get(0).getType()); + assertEquals("异常待处置", feed.get(0).getTitle()); + } + + @Test + @DisplayName("两类条目按各自业务发生时间倒序混排") + void feedIsMergedByBusinessTime() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7203L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(2, feed.size()); + assertEquals(List.of("reassign", "exception"), + feed.stream().map(AdminTodoItemVO::getType).collect(Collectors.toList()), + "拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前"); + } + + @Test + @DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与") + void reassignQueryExcludesTerminalStatus() { + service.buildAdminFeed(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); + String sql = wrapper.getTargetSql(); + + assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql); + assertTrue(sql.contains("business_status"), + "已取消/已完成的运单不该再催重新派单:" + sql); + + Set values = new HashSet<>(wrapper.getParamNameValuePairs().values()); + assertTrue(values.contains("rejected"), "要带上已拒绝:" + values); + assertTrue(values.contains("pending") && values.contains("running"), + "业务状态只应放待执行与进行中:" + values); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill rejectedWaybill(Long id, String driverName, String reason) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(REJECTED_WAYBILL_NO); + waybill.setDriverName(driverName); + waybill.setDriverAcceptStatus("rejected"); + waybill.setBusinessStatus("pending"); + waybill.setDriverRejectReason(reason); + waybill.setDriverRejectTime(yesterdayAt22()); + return waybill; + } + + private ExceptionDisposal exceptionDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(EXCEPTION_ID); + disposal.setWaybillNo("YD-20261006-0001"); + disposal.setReporterName("黄宇富"); + disposal.setExceptionType("交通异常"); + disposal.setDisposalStatus("pending"); + disposal.setReportTime(LocalDateTime.now().minusHours(26)); + return disposal; + } + + private Date yesterdayAt22() { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.add(java.util.Calendar.DAY_OF_MONTH, -1); + calendar.set(java.util.Calendar.HOUR_OF_DAY, 22); + calendar.set(java.util.Calendar.MINUTE, 0); + calendar.set(java.util.Calendar.SECOND, 0); + calendar.set(java.util.Calendar.MILLISECOND, 0); + return calendar.getTime(); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java new file mode 100644 index 00000000..e4639e36 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -0,0 +1,378 @@ +package org.springblade.transport.service.impl; + +import com.alibaba.fastjson2.JSON; +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.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.mapper.TransportCertRecordMapper; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.service.IDriverAppService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportCertRecordService; +import org.springblade.transport.service.ITransportVehicleService; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 个人认证的「提交 → 生效值不变 → 审核通过 → 生效值更新」闭环测试。 + *

+ * 这是司机认证最关键的一条业务闭环,也是单看提交侧或审核侧都验证不到的: + * 提交之后司机侧查到的必须仍是**旧生效值**(否则等于提交即自动认证), + * 审核通过之后才变成**新值**(否则认证永远不生效)。 + *

+ * 用一个内存版的记录存储把两侧接起来,避免两边各自 mock 成「恰好一致」。 + */ +class PersonCertRoundTripTest { + + private static final Long DRIVER_ID = 9001L; + + private IDriverAppService driverAppService; + private IDriverService driverService; + private ITransportCertRecordService certRecordService; + private TransportCertRecordMapper certRecordMapper; + private DriverCertServiceImpl driverCertService; + private CertAuditServiceImpl certAuditService; + + /** + * 内存记录表:Id → 记录 + */ + private final Map recordStore = new HashMap<>(); + private Driver driver; + private long idSequence = 100L; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + + @BeforeEach + void setUp() { + recordStore.clear(); + idSequence = 100L; + + driverAppService = mock(IDriverAppService.class); + driverService = mock(IDriverService.class); + certRecordService = mock(ITransportCertRecordService.class); + certRecordMapper = mock(TransportCertRecordMapper.class); + ITransportVehicleService transportVehicleService = mock(ITransportVehicleService.class); + + driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setDriverName("张三"); + when(driverAppService.currentDriverEntity()).thenAnswer(invocation -> driver); + // 司机表只持有指针,指针是被审核链路改的,这里让桩跟着内存状态走; + // 证件字段也要跟着落地,否则「审核通过写回档案」这条链路在测试里验不到 + when(driverService.updateById(any(Driver.class))).thenAnswer(invocation -> { + Driver patch = invocation.getArgument(0); + if (patch.getEffectiveCertRecordId() != null) { + driver.setEffectiveCertRecordId(patch.getEffectiveCertRecordId()); + } + copyCertFields(patch, driver); + return true; + }); + + stubRecordStore(); + + driverCertService = new DriverCertServiceImpl(driverAppService, driverService, + transportVehicleService, certRecordService); + certAuditService = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService); + } + + @Test + @DisplayName("提交后仍是旧生效值,审核通过后才变新值") + void submitThenApprove() { + // 1) 先走一遍完整流程,让该司机有一个生效值 + Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + certAuditService.audit(firstRecordId, true, null); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "审核通过后应能查到生效值"); + + // 2) 提交新内容:生效值必须**不变** + Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "提交只产生待审记录,不得改变生效值——否则等于提交即自动认证"); + assertEquals(firstRecordId, driver.getEffectiveCertRecordId(), + "指针应仍指向原来那条已通过记录"); + + // 3) 审核通过:生效值才变成新值 + certAuditService.audit(secondRecordId, true, null); + assertEquals("张三改名", driverCertService.getEffectivePersonCert().getRealName(), + "审核通过后生效值应切到新记录"); + assertEquals(secondRecordId, driver.getEffectiveCertRecordId()); + } + + @Test + @DisplayName("驳回后生效值不变(已认证对象不会被驳回下线)") + void rejectKeepsEffectiveValue() { + Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + certAuditService.audit(firstRecordId, true, null); + + Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); + certAuditService.audit(secondRecordId, false, "证件照片模糊"); + + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "驳回只终结该次提交,既得认证必须保持有效"); + assertEquals(firstRecordId, driver.getEffectiveCertRecordId()); + } + + @Test + @DisplayName("从未认证过时,审核通过前查不到生效值") + void noEffectiveBeforeFirstApproval() { + assertNull(driverCertService.getEffectivePersonCert()); + + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + assertNull(driverCertService.getEffectivePersonCert(), + "待审期间不应有生效值"); + + certAuditService.audit(recordId, true, null); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName()); + } + + @Test + @DisplayName("连续提交两次,不存在两条待审,且旧待审被作废") + void consecutiveSubmitsLeaveSinglePending() { + Long first = driverCertService.submitPersonCert(personCert("第一次", "110101199001011234")); + Long second = driverCertService.submitPersonCert(personCert("第二次", "110101199001011234")); + + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, recordStore.get(first).getStatus(), + "前一条待审应被作废"); + assertEquals(TransportCertRecord.STATUS_PENDING, recordStore.get(second).getStatus()); + + long pendingCount = recordStore.values().stream() + .filter(record -> TransportCertRecord.STATUS_PENDING.equals(record.getStatus())) + .count(); + assertEquals(1L, pendingCount, "同一司机至多一条待审"); + + // 被作废的那条不能再被审核(否则会把已作废的记录变成生效值) + org.junit.jupiter.api.Assertions.assertThrows( + org.springblade.core.log.exception.ServiceException.class, + () -> certAuditService.audit(first, true, null)); + } + + @Test + @DisplayName("已通过的记录不可再被审核(防止把生效快照改坏)") + void approvedRecordCannotBeReaudited() { + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + certAuditService.audit(recordId, true, null); + + org.junit.jupiter.api.Assertions.assertThrows( + org.springblade.core.log.exception.ServiceException.class, + () -> certAuditService.audit(recordId, false, "事后反悔")); + assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), + "重复审核被拒后,生效值不受影响"); + } + + // ------------------------------------------------------------ 内存记录存储 + + /** + * 用内存 Map 承接记录读写,让两个服务真实地共享同一份状态 + */ + private void stubRecordStore() { + when(certRecordService.save(any(TransportCertRecord.class))).thenAnswer(invocation -> { + TransportCertRecord record = invocation.getArgument(0); + record.setId(++idSequence); + recordStore.put(record.getId(), copyOf(record)); + return true; + }); + when(certRecordService.updateById(any(TransportCertRecord.class))).thenAnswer(invocation -> { + TransportCertRecord patch = invocation.getArgument(0); + TransportCertRecord stored = recordStore.get(patch.getId()); + if (stored != null) { + if (patch.getStatus() != null) { + stored.setStatus(patch.getStatus()); + } + stored.setRejectReason(patch.getRejectReason()); + stored.setAuditTime(patch.getAuditTime()); + stored.setAuditUser(patch.getAuditUser()); + } + return true; + }); + when(certRecordService.getById(anyLong())) + .thenAnswer(invocation -> recordStore.get(invocation.getArgument(0, Long.class))); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenAnswer(invocation -> { + String bizType = invocation.getArgument(0); + Long bizId = invocation.getArgument(1); + return recordStore.values().stream() + .filter(record -> bizType.equals(record.getBizType()) + && bizId.equals(record.getBizId()) + && TransportCertRecord.STATUS_PENDING.equals(record.getStatus())) + .findFirst() + .orElse(null); + }); + } + + private TransportCertRecord copyOf(TransportCertRecord source) { + return JSON.parseObject(JSON.toJSONString(source), TransportCertRecord.class); + } + + /** + * 模拟框架的 not_null 更新策略:patch 里为 null 的列不写,非 null 才覆盖 + *

+ * 单测里直接整对象覆盖会丢掉这个语义,从而验不出「管理端填过、司机端没填的字段被清空」 + * 这类只有真实更新策略才暴露的问题。 + */ + private void copyCertFields(Driver patch, Driver target) { + if (patch.getDriverName() != null) { + target.setDriverName(patch.getDriverName()); + } + if (patch.getIdCardNo() != null) { + target.setIdCardNo(patch.getIdCardNo()); + } + if (patch.getIdCardFront() != null) { + target.setIdCardFront(patch.getIdCardFront()); + } + if (patch.getIdCardBack() != null) { + target.setIdCardBack(patch.getIdCardBack()); + } + if (patch.getHeadPhoto() != null) { + target.setHeadPhoto(patch.getHeadPhoto()); + } + if (patch.getDrivingType() != null) { + target.setDrivingType(patch.getDrivingType()); + } + if (patch.getDrivingLicenseNo() != null) { + target.setDrivingLicenseNo(patch.getDrivingLicenseNo()); + } + if (patch.getDrivingLicenseFront() != null) { + target.setDrivingLicenseFront(patch.getDrivingLicenseFront()); + } + if (patch.getDrivingLicenseBack() != null) { + target.setDrivingLicenseBack(patch.getDrivingLicenseBack()); + } + if (patch.getQualificationType() != null) { + target.setQualificationType(patch.getQualificationType()); + } + if (patch.getQualificationNo() != null) { + target.setQualificationNo(patch.getQualificationNo()); + } + if (patch.getQualificationFront() != null) { + target.setQualificationFront(patch.getQualificationFront()); + } + if (patch.getQualificationBack() != null) { + target.setQualificationBack(patch.getQualificationBack()); + } + } + + private PersonCertSubmitRequest personCert(String realName, String idCardNo) { + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName(realName); + request.setIdCardNo(idCardNo); + request.setIdCardFrontUrl("http://example.com/front.jpg"); + request.setIdCardBackUrl("http://example.com/back.jpg"); + return request; + } + + @Test + @DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致") + void approveWritesCertContentBackToDriver() { + PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); + request.setHeadPhoto("http://example.com/head.jpg"); + request.setDriverClass("A2"); + request.setDriverFileNo("450100769875"); + request.setDriverLicenseFrontUrl("http://example.com/dl-front.jpg"); + request.setDriverLicenseBackUrl("http://example.com/dl-back.jpg"); + request.setQualificationType("道路旅客运输"); + request.setQualificationNo("Q0001"); + request.setQualificationFront("http://example.com/q-front.jpg"); + request.setQualificationBack("http://example.com/q-back.jpg"); + + Long recordId = driverCertService.submitPersonCert(request); + certAuditService.audit(recordId, true, null); + + // 档案里必须是这次提交的内容:只切指针、不写内容的话, + // 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的 + assertEquals("http://example.com/head.jpg", driver.getHeadPhoto()); + assertEquals("A2", driver.getDrivingType()); + assertEquals("450100769875", driver.getDrivingLicenseNo()); + assertEquals("http://example.com/dl-front.jpg", driver.getDrivingLicenseFront()); + assertEquals("http://example.com/dl-back.jpg", driver.getDrivingLicenseBack()); + assertEquals("道路旅客运输", driver.getQualificationType()); + assertEquals("Q0001", driver.getQualificationNo()); + assertEquals("http://example.com/q-front.jpg", driver.getQualificationFront()); + assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack()); + } + + @Test + @DisplayName("审核通过:姓名与身份证号跟着认证资料走,档案与生效值不再各说各话") + void approveOverwritesIdentity() { + driver.setDriverName("张三(档案名)"); + driver.setIdCardNo("110101199001011234"); + + Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); + certAuditService.audit(recordId, true, null); + + // 审核通过即认可这次申报:档案里必须也是申报的姓名, + // 否则司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字 + assertEquals("张三改名", driver.getDriverName()); + assertEquals("110101199001011234", driver.getIdCardNo()); + } + + @Test + @DisplayName("审核通过:申报的身份证号已属于另一名司机时阻断审核,不得产生两条同号档案") + void approveRejectsIdCardTakenByAnotherDriver() { + Driver other = new Driver(); + other.setId(8888L); + other.setIdCardNo("110101199001011999"); + when(driverService.getOne(any())).thenReturn(other); + + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011999")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> certAuditService.audit(recordId, true, null)); + assertTrue(exception.getMessage().contains("另一名司机档案"), exception.getMessage()); + // 真实环境下这次审核由 @Transactional 整体回滚(记录仍是待审); + // 单测没有事务,故这里校验更直接的一点:拦截发生在写档案之前,别人的证件号没被写进来 + assertNull(driver.getIdCardNo(), "不得把属于别人的证件号写进当前档案"); + } + + @Test + @DisplayName("驳回:证件内容一个字都不写回档案") + void rejectWritesNothingBack() { + driver.setHeadPhoto("http://example.com/original-head.jpg"); + + PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); + request.setHeadPhoto("http://example.com/new-head.jpg"); + Long recordId = driverCertService.submitPersonCert(request); + certAuditService.audit(recordId, false, "证件照片模糊"); + + assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(), + "驳回只终结该次提交,档案内容不得被改动"); + } + + @Test + @DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过") + void legacySnapshotSkipsWriteBackWithoutFailingAudit() { + // 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null; + // 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败 + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); + TransportCertRecord record = recordStore.get(recordId); + record.setSnapshotJson("{\"legacyField\":\"早期格式的旧内容\"}"); + + certAuditService.audit(recordId, true, null); + + assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(), + "快照读不懂不该阻断审核"); + assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换"); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java new file mode 100644 index 00000000..c61dd1ec --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java @@ -0,0 +1,225 @@ +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.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +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 java.lang.reflect.Field; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +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.when; + +/** + * 管理端打卡时间轴的接单节点判定测试。 + *

+ * 接单节点不落打卡流水,是否完成由运单的接单事实决定:司机确认接单即为已打卡; + * 项目「无需确认接单」时运单越过接单阶段即为已打卡,但没有完成时刻。 + * 这条口径若被改成「看打卡表」,时间轴上的接单会退回恒显示未打卡。 + */ +class WaybillPunchRecordListTest { + + private static final Long WAYBILL_ID = 6101L; + private static final String WAYBILL_NO = "YD202610060002"; + + /** 需确认接单:接单节点 + 一个普通打卡节点 */ + private static final String CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"yes\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + /** 无需确认接单 */ + private static final String NO_CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"no_confirm_accept\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", enroutePunchMapper); + } + + @Test + @DisplayName("司机确认接单:接单节点显示已打卡,时间取接单时间") + void acceptNodePunchedWhenDriverAccepted() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + waybill.setDriverAcceptTime(parseTime("2026-10-06 10:04:07")); + stubWaybill(waybill); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("2026-10-06 10:04:07", accept.getPunchTime()); + assertNull(accept.getId(), "派生记录没有打卡流水,不能带流水 id"); + } + + @Test + @DisplayName("待接单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenPending() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "pending")); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertFalse(accept.getPunched()); + assertEquals("未打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime()); + } + + @Test + @DisplayName("已拒单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenRejected() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "rejected")); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单且已进入进行中:接单节点已打卡,但没有完成时刻") + void acceptNodePunchedWithoutTimeWhenNoConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "running", null)); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime(), "没有接单动作就没有完成时刻,不得拿别的字段编一个"); + } + + @Test + @DisplayName("无需确认接单但仍是草稿:接单节点未打卡") + void acceptNodeUnpunchedWhenDraft() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "draft", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单但停在待执行:接单节点未打卡") + void acceptNodeUnpunchedWhenPendingWithoutConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "pending", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("已取消的无需确认接单运单:判为已打卡——状态本身不带是否曾进入进行中的历史") + void acceptNodePunchedWhenCancelled() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "cancelled", null)); + + assertTrue(acceptRecord().getPunched()); + } + + @Test + @DisplayName("接单节点的派生不影响普通节点:有流水的普通节点照旧已打卡并带流水 id") + void otherNodesStillReadPunchTable() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "running", "accepted")); + WaybillNodePunch loadPunch = new WaybillNodePunch(); + loadPunch.setId(7001L); + loadPunch.setWaybillId(WAYBILL_ID); + loadPunch.setNodeCode("load"); + loadPunch.setNodeName("装货"); + loadPunch.setPunchTime(parseTime("2026-10-06 09:30:00")); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(loadPunch)); + + WaybillPunchRecordItemVO load = service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "load".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + assertTrue(load.getPunched()); + assertEquals("已打卡", load.getStatusName()); + assertEquals("2026-10-06 09:30:00", load.getPunchTime()); + assertEquals(7001L, load.getId()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubWaybill(Waybill waybill) { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + } + + private WaybillPunchRecordItemVO acceptRecord() { + return service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "accept".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有接单节点")); + } + + private Waybill waybill(String processJson, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } + + private static Date parseTime(String text) { + try { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text); + } catch (ParseException exception) { + throw new IllegalArgumentException(text, exception); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java new file mode 100644 index 00000000..6b41a1ef --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -0,0 +1,380 @@ +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.update.LambdaUpdateWrapper; +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.springblade.core.log.exception.ServiceException; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +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 java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.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.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 管理端打卡记录编辑测试。 + *

+ * 守住几件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来 + * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段; + * 订正的照片只限「每类各自最多 9 张」、不校验必填——订正的是已经发生的事实,不是重新走一遍流程。 + */ +class WaybillPunchRecordSaveTest { + + private static final Long WAYBILL_ID = 6001L; + private static final String WAYBILL_NO = "YD202610060001"; + private static final String PROCESS_JSON = "[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true," + + "\"punch\":true,\"location\":true,\"uploadCargo\":true,\"cargoTypes\":[\"重量\",\"数量\"]," + + "\"uploadVoucher\":true,\"voucherTypes\":[\"委托单\"]}]"; + + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", enroutePunchMapper); + } + + // ------------------------------------------------------------ 过程节点打卡 + + @Test + @DisplayName("补录未打卡节点:新建流水,节点取过程配置,不记司机、异常标记为否") + void insertNodePunchWhenNoneExists() { + 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"); + request.setWeight("12.5"); + request.setRemark("补录"); + request.setPhotos(List.of(photo("委托单", "https://oss/a.jpg"))); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + WaybillNodePunch saved = captor.getValue(); + // 大小写不一致也应按过程配置回写节点 key,否则司机端按 key 查不到这条打卡 + assertEquals("load", saved.getNodeCode()); + assertEquals("装货", saved.getNodeName()); + assertEquals(WAYBILL_ID, saved.getWaybillId()); + assertEquals(WAYBILL_NO, saved.getWaybillNo()); + assertNotNull(saved.getPunchTime()); + assertEquals("12.5", saved.getWeight()); + assertEquals(0, saved.getExceptionFlag()); + assertNull(saved.getDriverId(), "管理端补录不能记到司机头上"); + assertNotNull(saved.getPhotos()); + assertTrue(saved.getPhotos().contains("https://oss/a.jpg"), "照片URL应写入 photos"); + assertTrue(saved.getPhotos().contains("委托单"), "凭证类型应随照片一起写入"); + verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("编辑已打卡节点:改最新一条,不新建,也不改写异常标记") + @SuppressWarnings("unchecked") + void updateLatestNodePunch() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillNodePunch existed = nodePunch(7001L, "load", "装货"); + existed.setExceptionFlag(1); + existed.setWeight("10"); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(existed)); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPunchTime("2026-10-06 11:00:00"); + request.setWeight("20"); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(nodePunchMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains("20"), "重量应写进更新语句"); + // updateById 会跳过 null 字段:清空地址/货量/照片必须由显式 set 写 null 才生效 + assertTrue(values.contains(null), "被清空的字段必须显式写 null"); + assertFalse(wrapper.getSqlSet().contains("exception_flag"), + "异常标记与异常处置表绑定,编辑打卡不得动它"); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("取消打卡:清空该节点全部流水,历史流水不得顶上来") + @SuppressWarnings("unchecked") + void deleteAllNodePunchesWhenUnpunched() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())) + .thenReturn(List.of(nodePunch(7001L, "load", "装货"), nodePunch(7002L, "load", "装货"))); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(nodePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(7001L, 7002L), new HashSet<>(captor.getValue())); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定") + void rejectAcceptNodePunchEdit() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + + WaybillPunchRecordSaveRequest punched = request("node", "accept", "接单"); + punched.setPunched(true); + assertThrows(ServiceException.class, () -> service.savePunchRecord(punched)); + + WaybillPunchRecordSaveRequest unpunched = request("node", "accept", "接单"); + unpunched.setPunched(false); + assertThrows(ServiceException.class, () -> service.savePunchRecord(unpunched)); + + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(nodePunchMapper, never()).deleteBatchIds(any()); + } + + // ------------------------------------------------------------ 打卡照片张数 + + @Test + @DisplayName("同一类型第 9 张可保存:上限按类型各自计数") + void acceptNinthPhotoOfSameType() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(sameTypePhotos("委托单", 9)); + + assertTrue(service.savePunchRecord(request)); + } + + @Test + @DisplayName("同一类型第 10 张被拒且不落库") + void rejectTenthPhotoOfSameType() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(sameTypePhotos("委托单", 10)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.savePunchRecord(request)); + + assertTrue(exception.getMessage().contains("委托单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("订正不校验照片必填:一张不传也能保存") + void missingPhotosDoNotBlockPunchRecordEdit() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(List.of()); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + assertNull(captor.getValue().getPhotos()); + } + + // ------------------------------------------------------------ 在途打卡 + + @Test + @DisplayName("编辑在途打卡:改最新一条,单张照片字段只留第一张") + @SuppressWarnings("unchecked") + void updateEnroutePunchKeepsFirstPhoto() { + 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(true); + request.setPunchTime("2026-10-06 12:00:00"); + request.setAddress("沪昆高速"); + request.setPhotos(List.of( + photo("货物照片", "https://oss/a.jpg"), + photo("货物照片", "https://oss/b.jpg"))); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(enroutePunchMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains("https://oss/a.jpg"), "只保留第一张照片"); + assertFalse(values.contains("https://oss/b.jpg"), "第二张不应写入"); + assertTrue(values.contains("沪昆高速")); + assertTrue(wrapper.getSqlSet().contains("photo"), "照片字段必须显式更新"); + verify(enroutePunchMapper, never()).insert(any(WaybillEnroutePunch.class)); + } + + @Test + @DisplayName("取消在途打卡:清空该运单全部在途流水") + @SuppressWarnings("unchecked") + void deleteAllEnroutePunchesWhenUnpunched() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillEnroutePunch first = new WaybillEnroutePunch(); + first.setId(8001L); + WaybillEnroutePunch second = new WaybillEnroutePunch(); + second.setId(8002L); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of(first, second)); + + WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途"); + request.setPunched(false); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(enroutePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(8001L, 8002L), new HashSet<>(captor.getValue())); + } + + // ------------------------------------------------------------ 入参校验 + + @Test + @DisplayName("运单不存在:拒绝保存") + void rejectMissingWaybill() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(null); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + } + + @Test + @DisplayName("打卡时间格式非法:报错而不是静默改成当前时间") + void rejectInvalidPunchTime() { + 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"); + + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("经纬度非法:报错且不写库") + void rejectInvalidCoordinate() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setLongitude("东经120度"); + + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setProcessJson(PROCESS_JSON); + waybill.setIsDeleted(0); + return waybill; + } + + private WaybillNodePunch nodePunch(Long id, String nodeCode, String nodeName) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(id); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + punch.setNodeName(nodeName); + return punch; + } + + private WaybillPunchRecordSaveRequest request(String type, String nodeCode, String nodeName) { + WaybillPunchRecordSaveRequest request = new WaybillPunchRecordSaveRequest(); + request.setWaybillId(WAYBILL_ID); + request.setType(type); + request.setNodeCode(nodeCode); + request.setNodeName(nodeName); + return request; + } + + private WaybillPunchRecordSaveRequest.Photo photo(String type, String url) { + WaybillPunchRecordSaveRequest.Photo photo = new WaybillPunchRecordSaveRequest.Photo(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private List sameTypePhotos(String type, int count) { + List photos = new ArrayList<>(); + for (int i = 0; i < count; i++) { + photos.add(photo(type, "https://oss/" + type + i + ".jpg")); + } + return photos; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java new file mode 100644 index 00000000..f2f7e9f9 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java @@ -0,0 +1,114 @@ +package org.springblade.transport.support; + +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 审核列表自定义 SQL 的语句级验证。 + *

+ * 这条 SQL 有两个容易写坏、写坏了又不会报错的地方: + *

    + *
  • 车型侧要按提交司机定位司机档案。车辆表没有司机列, + * 定位靠 {@code dr.user_id = r.submit_user}。少了这个条件, + * 车辆记录的司机列会全部取不到值,而页面只是「空着」——不像故障。
  • + *
  • 司机侧不能跟着走 submit_user。存量订正记录没有提交人, + * 若司机侧也用 submit_user 定位,23 条有司机的记录反而会全部变空。
  • + *
+ * 这里直接编译 Mapper XML 并检查生成出来的语句,把「靠约定」变成「靠语句」。 + */ +class CertAuditPageSqlTest { + + private static Configuration configuration; + + @BeforeAll + static void loadMapperXml() { + configuration = new Configuration(); + String resource = "org/springblade/transport/mapper/TransportCertRecordMapper.xml"; + try (InputStream input = CertAuditPageSqlTest.class.getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input, "找不到 Mapper XML:" + resource + "(它必须与 Mapper 接口同包放在 src/main/java 下)"); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } catch (Exception exception) { + throw new IllegalStateException("Mapper XML 解析失败:" + exception.getMessage(), exception); + } + } + + @Test + @DisplayName("无搜索条件:仍按状态与对象类型过滤,并按提交时间倒序") + void baseConditions() { + String sql = sql(Map.of()); + + assertTrue(sql.contains("r.status = ?"), "必须按审核状态过滤"); + assertTrue(sql.contains("r.biz_type = ?"), "必须按认证对象类型过滤"); + assertTrue(sql.contains("r.is_deleted = 0"), "逻辑删除的记录不得出现在审核列表"); + assertTrue(sql.contains("ORDER BY") && sql.contains("submit_time DESC"), "必须按提交时间倒序"); + } + + @Test + @DisplayName("司机档案定位:司机侧按对象主键,车辆侧按提交司机(user_id)") + void driverResolution() { + String sql = sql(Map.of()); + + assertTrue(sql.contains("WHEN r.biz_type = 'driver' THEN r.biz_id"), + "司机认证记录的司机就是认证对象本身"); + assertTrue(sql.contains("dr.user_id = r.submit_user"), + "车辆认证记录没有司机列,只能靠提交司机定位——少了它车辆页的司机列会整列取不到值"); + } + + @Test + @DisplayName("司机档案收敛为一条:子查询必须带 ORDER BY ... LIMIT 1") + void driverResolutionCollapsesToOneRow() { + String sql = sql(Map.of()); + + assertTrue(sql.contains("ORDER BY dr.update_time DESC") && sql.contains("LIMIT 1"), + "user_id 上没有唯一索引,两条司机档案指向同一用户时会让一条记录在列表里出现多行、总数虚高"); + } + + @Test + @DisplayName("空搜索条件不产生任何 LIKE(不能把「没填」当成筛选条件)") + void blankConditionsAddNoFilter() { + String sql = sql(Map.of()); + + assertFalse(sql.contains("LIKE"), "没传搜索项时不应出现 LIKE:空串会被拼成 LIKE '%%'"); + } + + @Test + @DisplayName("姓名搜索:同时匹配档案当前姓名与提交时的姓名") + void nameSearchCoversBothCurrentAndHistorical() { + String sql = sql(Map.of("driverName", "黄")); + + assertTrue(sql.contains("d.driver_name LIKE"), "必须能按档案当前姓名搜到"); + assertTrue(sql.contains("r.biz_key LIKE"), "改名后按旧名也要能搜到,否则审核员得两头试"); + } + + @Test + @DisplayName("手机号搜索:只按司机档案的手机号匹配(快照里从来没有手机号)") + void mobileSearchUsesProfileOnly() { + String sql = sql(Map.of("driverMobile", "1208")); + + assertTrue(sql.contains("d.mobile LIKE"), "手机号只有司机档案一个来源"); + } + + private String sql(Map params) { + MappedStatement statement = configuration.getMappedStatement( + "org.springblade.transport.mapper.TransportCertRecordMapper.selectAuditPage"); + Map parameters = new HashMap<>(params); + parameters.put("bizType", "driver"); + parameters.put("status", 0); + BoundSql boundSql = statement.getBoundSql(parameters); + return boundSql.getSql(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java new file mode 100644 index 00000000..4422d9cd --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java @@ -0,0 +1,99 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.secure.auth.AuthFun; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 认证审核权限表达式的可求值性测试。 + *

+ * 这些字符串写在 {@code @PreAuth} 注解里,由框架在**请求时**交给 SpEL 求值, + * root object 是 {@code AuthFun}。名字写错不会在编译期或启动期暴露, + * 只会在真实调用时抛异常(表现为接口 500)——用测试把它提前到构建期。 + *

+ * 这里用同一个 {@link SpelExpressionParser} 与同一套 root object 语义求值, + * 与框架 {@code AuthAspect} 的做法一致。 + */ +class CertAuditPermissionsTest { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + @Test + @DisplayName("查看权限:拥有司机审核菜单即可通过") + void auditViewWithDriverMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true); + + assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun), + "只授予司机审核菜单的用户也应能查看待审列表"); + } + + @Test + @DisplayName("查看权限:拥有车辆审核菜单即可通过") + void auditViewWithVehicleMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("cert_certification_audit_vehicle")).thenReturn(true); + + assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun)); + } + + @Test + @DisplayName("查看权限:两个菜单都没有则拒绝") + void auditViewWithoutAnyMenu() { + assertFalse(evaluate(CertAuditPermissions.AUDIT_VIEW, mock(AuthFun.class)), + "无任何审核菜单的用户不得查看待审列表"); + } + + @Test + @DisplayName("审核动作:只有查看权限、没有审核按钮权限时必须拒绝") + void auditActionRequiresButtonPermission() { + AuthFun authFun = mock(AuthFun.class); + // 只给了列表菜单,没给「审核」按钮 + when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true); + + assertFalse(evaluate(CertAuditPermissions.AUDIT_ACTION, authFun), + "能看待审列表不等于能执行审核"); + } + + @Test + @DisplayName("审核动作:任一审核按钮权限即可通过") + void auditActionWithEitherButton() { + AuthFun driverAuditor = mock(AuthFun.class); + when(driverAuditor.hasMenu("cert_certification_audit_driver_audit")).thenReturn(true); + assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, driverAuditor)); + + AuthFun vehicleAuditor = mock(AuthFun.class); + when(vehicleAuditor.hasMenu("cert_certification_audit_vehicle_audit")).thenReturn(true); + assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, vehicleAuditor)); + } + + @Test + @DisplayName("表达式必须引用真实存在的菜单码(与票 01 建的一致)") + void expressionsReferenceRealMenuCodes() { + assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_driver")); + assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_vehicle")); + assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_driver_audit")); + assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_vehicle_audit")); + // 不应再出现角色名判断:库里不存在 admin 角色,且会漏掉「运管」岗位 + assertFalse(CertAuditPermissions.AUDIT_VIEW.contains("hasAnyRole")); + assertFalse(CertAuditPermissions.AUDIT_ACTION.contains("hasAnyRole")); + } + + /** + * 用与框架相同的方式求值:AuthFun 作为 root object + */ + private boolean evaluate(String expression, AuthFun authFun) { + Expression parsed = parser.parseExpression(expression); + StandardEvaluationContext context = new StandardEvaluationContext(authFun); + return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class)); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java new file mode 100644 index 00000000..8a9bbb0f --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java @@ -0,0 +1,120 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 认证可用性门槛测试。 + *

+ * 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照, + * 而重新提交会把「最新提交状态」变成「审核中」。 + */ +class CertificationGateTest { + + private CertificationGate gate; + + @BeforeEach + void setUp() { + gate = new CertificationGate(); + } + + // ------------------------------------------------------------ 开关 + + @Test + @DisplayName("门槛默认开启") + void enabledByDefault() { + assertTrue(gate.isVehicleGateEnabled()); + assertTrue(gate.isDriverGateEnabled()); + } + + @Test + @DisplayName("关闭后不因未认证而拦截(便于快速回退)") + void disabledLetsEveryoneThrough() { + gate.setVehicleGateEnabled(false); + gate.setDriverGateEnabled(false); + + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null)); + assertTrue(gate.isDriverAvailable(null)); + } + + // ------------------------------------------------------------ 车辆 + + @Test + @DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)") + void vehicleWithEffectiveSnapshotIsAvailable() { + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L)); + } + + @Test + @DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用") + void resubmittedVehicleStaysAvailable() { + // 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。 + assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L), + "已认证的车重新提交后必须仍可接单,否则司机一提交就停工"); + } + + @Test + @DisplayName("车辆:从未认证(无生效快照)被拒绝") + void vehicleWithoutEffectiveSnapshotIsRejected() { + assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null)); + } + + @Test + @DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用") + void disabledVehicleIsRejectedEvenIfCertified() { + assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L), + "启用/停用是运营状态,认证门槛只叠加不覆盖"); + assertFalse(gate.isVehicleAvailable(null, 7001L)); + } + + // ------------------------------------------------------------ 司机 + + @Test + @DisplayName("司机:有生效快照即可用") + void driverWithEffectiveSnapshotIsAvailable() { + assertTrue(gate.isDriverAvailable(9001L)); + } + + @Test + @DisplayName("司机:从未认证被拒绝") + void driverWithoutEffectiveSnapshotIsRejected() { + assertFalse(gate.isDriverAvailable(null)); + } + + @Test + @DisplayName("司机门槛可单独关闭") + void driverGateCanBeDisabledIndependently() { + gate.setDriverGateEnabled(false); + assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截"); + assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响"); + } + + // ------------------------------------------------------------ 提示文案 + + @Test + @DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证") + void unavailableReasonsDistinguishDisabledFromUncertified() { + String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L); + assertTrue(disabled.contains("停用"), disabled); + assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过"); + + String uncertified = gate.vehicleUnavailableReason(Boolean.TRUE, null); + assertTrue(uncertified.contains("认证"), uncertified); + assertFalse(uncertified.contains("停用"), uncertified); + } + + @Test + @DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名") + void availableHasNoReasonAndTextIsReadable() { + 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")); + assertTrue(gate.driverUnavailableReason().contains("认证")); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java new file mode 100644 index 00000000..ebfad643 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java @@ -0,0 +1,123 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.TransportCertRecord; + +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; + +/** + * 认证记录状态机规则测试。 + *

+ * 这几条规则是整个认证改造的地基,一旦被改坏,症状是「司机重新提交认证后车辆掉出可用池」 + * 或「同一辆车出现两条待审」,都属线上事故,故集中在一处并锁住行为。 + */ +class CertificationRuleSupportTest { + + @Test + @DisplayName("提交:前一条待审必须转已作废,而不是被删除") + void supersedePending() { + TransportCertRecord previousPending = record(TransportCertRecord.STATUS_PENDING); + + TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(previousPending); + + // 返回的正是那条旧记录,且只改状态 + assertEquals(TransportCertRecord.STATUS_SUPERSEDED, superseded.getStatus()); + assertEquals(previousPending.getId(), superseded.getId()); + } + + @Test + @DisplayName("提交:前一条不是待审时,不得被改动(已通过/已驳回都是终态)") + void doesNotTouchNonPending() { + assertNull(CertificationRuleSupport.supersedeIfPending(null)); + assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_APPROVED))); + assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_REJECTED))); + assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_SUPERSEDED))); + } + + @Test + @DisplayName("提交:新记录一律是待审,且清空驳回原因(避免继承上一条的驳回文案)") + void newRecordIsPending() { + TransportCertRecord incoming = record(null); + incoming.setStatus(TransportCertRecord.STATUS_REJECTED); + incoming.setRejectReason("上一次的驳回原因"); + + CertificationRuleSupport.prepareForSubmit(incoming); + + assertEquals(TransportCertRecord.STATUS_PENDING, incoming.getStatus()); + assertNull(incoming.getRejectReason()); + } + + @Test + @DisplayName("生效快照判定:只看指针,与记录表里任何一条的状态都无关") + void effectiveIsDecidedByPointerOnly() { + assertFalse(CertificationRuleSupport.isCertified(null)); + assertTrue(CertificationRuleSupport.isCertified(123456789L)); + } + + @Test + @DisplayName("可审核判定:只有待审记录可被审核,终态一律拒绝(防止把生效快照改坏)") + void onlyPendingIsAuditable() { + assertTrue(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_PENDING))); + assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_APPROVED))); + assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_REJECTED))); + assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_SUPERSEDED))); + assertFalse(CertificationRuleSupport.isAuditable(null)); + } + + @Test + @DisplayName("审核入参:驳回必须给原因,通过时原因无意义需清空") + void auditPayload() { + // 驳回无原因 -> 拒绝 + assertFalse(CertificationRuleSupport.isRejectReasonValid(false, null)); + assertFalse(CertificationRuleSupport.isRejectReasonValid(false, " ")); + assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "证件照片模糊")); + + // 通过时不需要原因 + assertTrue(CertificationRuleSupport.isRejectReasonValid(true, null)); + + // 通过必须把原因清空,否则会残留上一次驳回文案 + assertNull(CertificationRuleSupport.reasonForApprove()); + assertEquals("证件照片模糊", CertificationRuleSupport.reasonForReject(" 证件照片模糊 ")); + } + + @Test + @DisplayName("驳回原因长度:超过 200 字拒绝(与列宽一致)") + void rejectReasonLength() { + assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "短".repeat(200))); + assertFalse(CertificationRuleSupport.isRejectReasonValid(false, "长".repeat(201))); + } + + @Test + @DisplayName("状态文案:四种状态各一个中文说法,未知状态不静默给空") + void statusText() { + assertEquals("审核中", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_PENDING)); + assertEquals("已通过", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_APPROVED)); + assertEquals("已驳回", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_REJECTED)); + assertEquals("已作废", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_SUPERSEDED)); + assertEquals("未知状态", CertificationRuleSupport.statusText(99)); + assertEquals("未知状态", CertificationRuleSupport.statusText(null)); + } + + @Test + @DisplayName("认证对象类型:只认 driver / vehicle,其它一律拒绝") + void bizTypeValidation() { + assertTrue(CertificationRuleSupport.isValidBizType("driver")); + assertTrue(CertificationRuleSupport.isValidBizType("vehicle")); + assertFalse(CertificationRuleSupport.isValidBizType("DRIVER")); + assertFalse(CertificationRuleSupport.isValidBizType("truck")); + assertFalse(CertificationRuleSupport.isValidBizType(null)); + assertFalse(CertificationRuleSupport.isValidBizType("")); + } + + private TransportCertRecord record(Integer status) { + TransportCertRecord record = new TransportCertRecord(); + record.setId(1000L); + record.setStatus(status); + return record; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java new file mode 100644 index 00000000..ff45d30a --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java @@ -0,0 +1,60 @@ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportVehicle; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 生效快照指针的 UPDATE 语句级验证。 + *

+ * 「驳回不得改变生效快照」这条不变式最终取决于:驳回时那次 UPDATE 会不会带上 + * {@code effective_cert_record_id} 列。审核服务传的是 null,因此只要该列被 + * 「值非 null 才写入」的 if 包住,驳回就物理上碰不到指针。 + *

+ * 这里直接检查 MyBatis-Plus 生成的 SET 片段,把「靠约定」变成「靠语句」—— + * 若哪天有人给该字段加了 {@code updateStrategy = ALWAYS},这个测试会立刻变红。 + */ +class EffectivePointerUpdateSqlTest { + + @Test + @DisplayName("车辆:指针列进入 UPDATE 片段,但被 null 判断包住(通过能写、驳回不碰)") + void vehiclePointerGuardedByNullCheck() { + assertPointerGuarded(TransportVehicle.class); + } + + @Test + @DisplayName("司机:同上") + void driverPointerGuardedByNullCheck() { + assertPointerGuarded(Driver.class); + } + + private void assertPointerGuarded(Class clazz) { + String updateSql = tableInfo(clazz).getAllSqlSet(true, "et"); + + assertTrue(updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId}"), + clazz.getSimpleName() + ":通过审核必须能写指针,否则生效快照永远建立不起来"); + + // 必须是「被 包住」的形式;若变成无条件的 SET, + // 驳回时(传 null)就会把已认证对象的指针清空,等于把车/司机无辜下线。 + assertTrue( + updateSql.contains("") + && updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId},"), + clazz.getSimpleName() + ":指针列必须是「值非 null 才写」;" + + "无条件 SET 会让驳回清空已认证对象的指针"); + } + + private TableInfo tableInfo(Class clazz) { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + return TableInfoHelper.initTableInfo(assistant, clazz); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java new file mode 100644 index 00000000..f082dc22 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java @@ -0,0 +1,73 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.secure.auth.AuthFun; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 异常处置权限表达式的可求值性测试。 + *

+ * 表达式写在 {@code @PreAuth} 注解里,由框架在请求时交给 SpEL 求值, + * root object 是 {@code AuthFun}。写成裸字符串(如 {@code "exception_disposal_edit"}) + * 会被当作属性访问而抛异常,表现为接口 500 而不是 403——用测试把它提前到构建期。 + * + * @author Chill + */ +class ExceptionDisposalPermissionsTest { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + @Test + @DisplayName("编辑权限:拥有编辑菜单即通过") + void editWithMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_edit")).thenReturn(true); + + assertTrue(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "授予编辑菜单的用户应能编辑"); + } + + @Test + @DisplayName("编辑权限:没有编辑菜单则拒绝") + void editWithoutMenu() { + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, mock(AuthFun.class)), + "未授予编辑菜单的用户不得编辑"); + } + + @Test + @DisplayName("编辑权限:只有查看/跟进菜单不能编辑") + void editRequiresDedicatedMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_view")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_follow")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_complete")).thenReturn(true); + + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "能跟进、能完成不等于能编辑"); + } + + @Test + @DisplayName("表达式必须引用真实存在的菜单码") + void expressionReferencesRealMenuCode() { + assertTrue(ExceptionDisposalPermissions.EDIT.contains("exception_disposal_edit")); + assertFalse(ExceptionDisposalPermissions.EDIT.contains("hasAnyRole")); + } + + /** + * 用与框架相同的方式求值:AuthFun 作为 root object + */ + private boolean evaluate(String expression, AuthFun authFun) { + Expression parsed = parser.parseExpression(expression); + StandardEvaluationContext context = new StandardEvaluationContext(authFun); + return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class)); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java new file mode 100644 index 00000000..52530a56 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java @@ -0,0 +1,223 @@ +package org.springblade.transport.support; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 百度 OCR 结果归一化测试。 + *

+ * 这一层挡的是「识别成功但什么都没填」——百度返回的是中文键,写死键名的代码在键名 + * 对不上的那天会静默失效:不抛异常、不报错,用户只看到识别完表单还是空的。 + * 故这里用真实形状的返回把别名容错与值归一化都钉住。 + */ +class OcrFieldSupportTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + @DisplayName("行驶证主页:官方字段名逐个归一化,外廓尺寸拆成三个") + void vehicleLicenseFront() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result_num": 12, + "words_result": { + "号牌号码": {"words": "桂A32523"}, + "车辆类型": {"words": "重型厢式货车"}, + "所有人": {"words": "桂物物流集团"}, + "车辆识别代码": {"words": "LGAX2A130J1001234"}, + "档案编号": {"words": "450100123456"}, + "核定载质量": {"words": "18000kg"}, + "准牵引总质量": {"words": "35000"}, + "外廓尺寸": {"words": "5990×2300×3100"}, + "强制报废期止": {"words": "2030-05-20"}, + "能源种类": {"words": "柴油"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertEquals("桂A32523", fields.get("plateNo")); + assertEquals("重型厢式货车", fields.get("vehicleType")); + assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("LGAX2A130J1001234", fields.get("vin")); + assertEquals("450100123456", fields.get("drivingLicenseNo")); + assertEquals(18000, fields.get("approvedLoadKg"), "带 kg 单位也要能取到数字"); + assertEquals(35000, fields.get("tractionMassKg")); + assertEquals(5990, fields.get("outerLength")); + assertEquals(2300, fields.get("outerWidth")); + assertEquals(3100, fields.get("outerHeight")); + assertEquals("2030-05-20", fields.get("compulsoryScrapDate")); + assertEquals("柴油", fields.get("energyType")); + } + + @Test + @DisplayName("键名写法差异:车牌号码 / 车辆识别代号 / 带单位后缀的键都要认") + void aliasTolerance() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "车牌号码": {"words": "桂B11111"}, + "车辆识别代号": {"words": "LVSHCAMB7HF123456"}, + "核定载质量(kg)": {"words": "12000"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertEquals("桂B11111", fields.get("plateNo")); + assertEquals("LVSHCAMB7HF123456", fields.get("vin")); + assertEquals(12000, fields.get("approvedLoadKg"), "带后缀的键名要靠「包含」兜底命中"); + } + + @Test + @DisplayName("日期写法:年月日 / 点分 / 纯数字都归一化成 yyyy-MM-dd") + void dateNormalization() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "强制报废期止": {"words": "2030年05月20日"}, + "注册日期": {"words": "2018.3.9"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertEquals("2030-05-20", fields.get("compulsoryScrapDate")); + assertEquals("2018-03-09", fields.get("drivingLicenseStartDate"), "月日个位数要补零"); + } + + @Test + @DisplayName("「长期」不产出日期,而是把对应的长期有效标志置 1") + void longTermBecomesFlag() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "强制报废期止": {"words": "长期"}, + "检验有效期止": {"words": "长期有效"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertFalse(fields.containsKey("compulsoryScrapDate"), "长期不能当成日期塞进日期框"); + assertEquals(1, fields.get("compulsoryScrapLongTerm")); + assertEquals(1, fields.get("drivingLicenseLongTerm")); + } + + @Test + @DisplayName("身份证与驾驶证:键名归一化到个人认证的字段名") + void idCardAndDrivingLicense() throws Exception { + JsonNode idCard = MAPPER.readTree(""" + { + "words_result": { + "姓名": {"words": "黄宇富"}, + "公民身份号码": {"words": "452730197911172318"}, + "签发日期": {"words": "2015-03-12"}, + "失效日期": {"words": "2035-03-12"} + } + } + """); + Map person = OcrFieldSupport.extract(BaiduOcrType.ID_CARD, "front", idCard); + assertEquals("黄宇富", person.get("realName")); + assertEquals("452730197911172318", person.get("idCardNo")); + assertEquals("2015-03-12", person.get("idCardValidStart")); + assertEquals("2035-03-12", person.get("idCardValidEnd")); + + JsonNode driving = MAPPER.readTree(""" + { + "words_result": { + "档案编号": {"words": "451200976793"}, + "准驾车型": {"words": "B2D"}, + "有效起始日期": {"words": "2016-08-01"}, + "有效期限": {"words": "2026-08-01"} + } + } + """); + Map license = OcrFieldSupport.extract(BaiduOcrType.DRIVING_LICENSE, "front", driving); + assertEquals("451200976793", license.get("driverFileNo")); + assertEquals("B2D", license.get("driverClass")); + assertEquals("2016-08-01", license.get("driverValidStart")); + assertEquals("2026-08-01", license.get("driverValidEnd")); + } + + @Test + @DisplayName("道路运输证:证号与业户名称") + void roadTransport() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "道路运输证号": {"words": "桂交运管桂字450100123456号"}, + "业户名称": {"words": "桂物物流集团"}, + "有效期": {"words": "2020-01-01 至 2030-01-01"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, null, result); + + assertEquals("桂交运管桂字450100123456号", fields.get("roadTransportCertNo")); + assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("2030-01-01", fields.get("roadTransportCertEndDate"), "止期字段取区间的最后一段,取第一段会把有效期起填成止"); + assertEquals("2020-01-01", fields.get("roadTransportCertStartDate"), "区间要同时带出起止,否则用户得照图再敲一遍"); + } + + @Test + @DisplayName("识别不到任何字段时返回空 Map,而不是一堆空串") + void emptyResult() throws Exception { + JsonNode result = MAPPER.readTree(""" + {"words_result_num": 0, "words_result": {}} + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertTrue(fields.isEmpty(), "空结果必须是空 Map:给空串会把用户已填内容覆盖掉"); + } + + @Test + @DisplayName("数值荒谬时不填:位数过多的串不可能是载质量,宁可留空也不填错") + void absurdNumberIsSkipped() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "核定载质量": {"words": "识别串行123456789012"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertFalse(fields.containsKey("approvedLoadKg")); + } + + @Test + @DisplayName("外廓尺寸只识别到两段时不写回:硬填会让宽高错位") + void outerSizeNeedsThreeParts() throws Exception { + JsonNode result = MAPPER.readTree(""" + { + "words_result": { + "外廓尺寸": {"words": "5990×2300"} + } + } + """); + + Map fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertFalse(fields.containsKey("outerLength")); + assertFalse(fields.containsKey("outerWidth")); + assertFalse(fields.containsKey("outerHeight")); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java new file mode 100644 index 00000000..698d21df --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java @@ -0,0 +1,122 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.ocr.constant.BaiduOcrType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 小程序证件类型 → 百度 OCR 证件类型的映射测试。 + *

+ * 两侧用的是两套命名,且**都不太直观**: + * 小程序把「行驶证」叫 {@code vehicle}、把「驾驶证」叫 {@code driver} + * (按业务直觉命名),而后端 OCR 沿用百度接口的 {@code vehicle_license} + * (行驶证)与 {@code driving_license}(驾驶证)。 + * 这两个词一一对应关系相反,映射写错会让「行驶证」被当成「驾驶证」识别, + * 结果是司机传了行驶证却拿到一堆驾驶证字段——而且不会报错。 + * 故此处逐一锁住。 + */ +class OcrTypeMappingTest { + + @Test + @DisplayName("身份证:小程序 idcard → 后端 ID_CARD") + void idCard() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("idcard")); + } + + @Test + @DisplayName("驾驶证:小程序 driver → 后端 DRIVING_LICENSE(不是 vehicle_license)") + void drivingLicense() { + assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driver")); + } + + @Test + @DisplayName("行驶证:小程序 vehicle → 后端 VEHICLE_LICENSE(不是 driving_license)") + void vehicleLicense() { + assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle")); + } + + @Test + @DisplayName("道路运输证:小程序 road_transport → 后端 ROAD_TRANSPORT_CERTIFICATE") + void roadTransport() { + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("road_transport")); + } + + @Test + @DisplayName("从业资格证:后端无对应 OCR 能力,必须明确报错而不是退化成通用识别") + void qualificationIsNotSilentlyDowngraded() { + // 百度没有「从业资格证」专用接口。若这里悄悄退化成 GENERAL, + // 前端会拿到一堆通用文本却以为识别成功了,故要求明确失败。 + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> OcrTypeMapping.toBaiduType("qualification")); + assertTrue(ex.getMessage().contains("从业资格证"), + "错误信息要指明是哪个证件类型不支持,便于前端提示用户手动填写"); + } + + @Test + @DisplayName("后端既有命名也接受(前端或其它调用方可能直接传后端名)") + void acceptsBackendNames() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id_card")); + assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driving_license")); + assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle_license")); + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("road_transport_certificate")); + } + + @Test + @DisplayName("大小写与分隔符不敏感") + void caseAndSeparatorInsensitive() { + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("IDCard")); + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType(" ID_CARD ")); + assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id-card")); + assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, + OcrTypeMapping.toBaiduType("Road-Transport")); + } + + @Test + @DisplayName("未知类型:明确报错,不静默降级为通用识别") + void unknownTypeFails() { + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("truck")); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("general")); + } + + @Test + @DisplayName("空值:明确报错") + void emptyTypeFails() { + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(null)); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("")); + assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(" ")); + } + + @Test + @DisplayName("正反面:小程序不传 side 时返回 null,交由知晓证件类型的一侧决定默认值") + void unsetSideStaysNull() { + // 这里恒返回 front 会让道路运输证恒失败:它没有正副面参数, + // 上游先填成 front,下游就变成「传了 side 但该证件不支持」—— + // 小程序不传 side 也会中招(实测三种传法全部报错)。 + assertNull(OcrTypeMapping.normalizeSide(null)); + assertNull(OcrTypeMapping.normalizeSide("")); + assertNull(OcrTypeMapping.normalizeSide(" ")); + assertEquals("front", OcrTypeMapping.normalizeSide("front")); + assertEquals("back", OcrTypeMapping.normalizeSide("back")); + assertEquals("back", OcrTypeMapping.normalizeSide("BACK")); + } + + @Test + @DisplayName("正反面:道路运输证不带正副面参数,其余证件默认正面") + void roadTransportHasNoSide() { + // 下游据此区分「默认取正面」与「该证件不该带这个参数」,故这条契约必须有测试守 + assertFalse(OcrTypeMapping.toBaiduType("road_transport").supportsSide(), + "道路运输证没有正副面参数,上游不得替它补 front"); + assertTrue(OcrTypeMapping.toBaiduType("vehicle").supportsSide()); + assertTrue(OcrTypeMapping.toBaiduType("driver").supportsSide()); + assertTrue(OcrTypeMapping.toBaiduType("idcard").supportsSide()); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java new file mode 100644 index 00000000..445e0419 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java @@ -0,0 +1,196 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.BeforeAll; +import org.springblade.core.log.exception.ServiceException; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import org.springblade.transport.pojo.entity.TransportVehicle; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 车牌匹配逻辑测试。 + *

+ * 司机档案的 {@code driving_vehicle} 与车辆表的 {@code plate_no} 是**文本软关联**, + * 无外键,且库里已知存在间隔符/横线/全角等写法差异(例如「沪AE23411」在车辆表中不存在, + * 而「桂A·32523」这类带间隔符的写法要靠规范化才能匹配上)。 + * 认证提交的越权校验依赖这套匹配,匹配漏判会让司机操作不了自己的车, + * 误判则会让司机改到别人的车,故用测试锁住行为。 + */ +class VehiclePlateSupportTest { + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @Test + @DisplayName("单个车牌:原样返回规范化结果") + void singlePlate() { + assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523")); + } + + @Test + @DisplayName("多个车牌:中英文逗号、顿号、分号、斜杠、竖线都算分隔符") + void multipleSeparators() { + assertEquals( + List.of("桂A32523", "桂B11111", "桂C22222"), + VehiclePlateSupport.splitPlates("桂A32523,桂B11111、桂C22222")); + assertEquals( + List.of("桂A32523", "桂B11111"), + VehiclePlateSupport.splitPlates("桂A32523;桂B11111")); + assertEquals( + List.of("桂A32523", "桂B11111"), + VehiclePlateSupport.splitPlates("桂A32523/桂B11111")); + assertEquals( + List.of("桂A32523", "桂B11111"), + VehiclePlateSupport.splitPlates("桂A32523|桂B11111")); + } + + @Test + @DisplayName("去重:同一车牌写两次只保留一条") + void dedupe() { + assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523,桂A32523")); + } + + @Test + @DisplayName("规范化:去空格/横线/间隔符并转大写") + void normalize() { + assertEquals("桂A32523", VehiclePlateSupport.normalize(" 桂a32523 ")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A-32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A·32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A•32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A.32523")); + assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A 32523")); + } + + @Test + @DisplayName("空值与脏数据:不抛异常,返回空串或空列表") + void emptyAndDirty() { + assertEquals("", VehiclePlateSupport.normalize(null)); + assertEquals("", VehiclePlateSupport.normalize(" ")); + assertTrue(VehiclePlateSupport.splitPlates(null).isEmpty()); + assertTrue(VehiclePlateSupport.splitPlates("").isEmpty()); + assertTrue(VehiclePlateSupport.splitPlates(",,、;;").isEmpty()); + } + + @Test + @DisplayName("绑定判定:写法差异不影响匹配(越权校验的关键)") + void isBound() { + // 司机档案里带间隔符,车辆表里是干净写法 + assertTrue(VehiclePlateSupport.isBound("桂A·32523", "桂A32523")); + // 大小写差异 + assertTrue(VehiclePlateSupport.isBound("桂a32523", "桂A32523")); + // 多车:命中其中一辆即可 + assertTrue(VehiclePlateSupport.isBound("桂A32523,桂B11111", "桂B11111")); + // 未绑定 + assertFalse(VehiclePlateSupport.isBound("桂A32523", "桂B11111")); + // 空值一律不绑定(不能因为双方都为空就放行——那等于越权) + assertFalse(VehiclePlateSupport.isBound(null, "桂A32523")); + assertFalse(VehiclePlateSupport.isBound("桂A32523", null)); + assertFalse(VehiclePlateSupport.isBound(null, null)); + assertFalse(VehiclePlateSupport.isBound("", "")); + } + + @Test + @DisplayName("SQL 规范化表达式必须覆盖 Java 侧剔除的全部字符(否则两边会悄悄分叉)") + void sqlNormalizationMirrorsJava() { + String sql = VehiclePlateSupport.NORMALIZED_PLATE_SQL; + // Java 的 normalize 会剔除这些排版字符并转大写,SQL 必须一一对应 + for (String noise : List.of("·", "•", ".", ".", "-", " ")) { + assertTrue(sql.contains("'" + noise + "',''"), + "SQL 规范化漏掉了字符 [" + noise + "],会导致该写法差异的车牌匹配不到"); + } + assertTrue(sql.contains("UPPER(plate_no)"), "SQL 必须先转大写再比对"); + assertTrue(sql.contains("plate_no"), "表达式作用的列应为 plate_no"); + } + + @Test + @DisplayName("车牌匹配条件是「精确 IN 或 规范化相等」,不能只有精确 IN") + void plateMatchIncludesNormalizedFallback() { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(wrapper, List.of("桂A32523")); + + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("plate_no IN"), "必须保留精确匹配,避免全表函数扫描"); + assertTrue(sql.contains("REPLACE"), "必须带规范化兜底,否则脏车牌匹配不到"); + assertTrue(sql.contains("OR"), "两者应是 OR 关系"); + } + + @Test + @DisplayName("绑定数上限:5 辆放行,第 6 辆拒绝") + void boundVehicleCountLimit() { + String five = "桂A32523,桂B11111,桂C22222,桂D33333,桂E44444"; + VehiclePlateSupport.validateBoundVehicles(five); + assertEquals(5, VehiclePlateSupport.splitPlates(five).size()); + + // 不抛异常就是通过;第 6 辆必须拦下,否则字段放不下会被静默截断 + ServiceException exception = assertThrows(ServiceException.class, + () -> VehiclePlateSupport.validateBoundVehicles(five + ",桂F55555")); + assertTrue(exception.getMessage().contains("最多绑定5辆车"), exception.getMessage()); + } + + @Test + @DisplayName("绑定校验:空值与单辆车都放行") + void boundVehicleEmptyAndSingle() { + VehiclePlateSupport.validateBoundVehicles(null); + VehiclePlateSupport.validateBoundVehicles(""); + VehiclePlateSupport.validateBoundVehicles("桂A32523"); + } + + @Test + @DisplayName("绑定校验:车牌格式不合法时拒绝,且报出是哪个车牌") + void boundVehicleRejectsBadPlate() { + ServiceException exception = assertThrows(ServiceException.class, + () -> VehiclePlateSupport.validateBoundVehicles("桂A32523,乱写的车牌")); + assertTrue(exception.getMessage().contains("车牌号格式不正确"), exception.getMessage()); + } + + @Test + @DisplayName("车牌格式:普通、新能源(8位)、挂车/学车/警车都合法") + void validPlateFormats() { + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A32523")); + assertTrue(VehiclePlateSupport.isValidPlateNo("沪AE31312")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234挂")); + assertTrue(VehiclePlateSupport.isValidPlateNo("京A1234学")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234警")); + // 写法差异不影响判定:小写与带间隔符的车牌先规范化再比对 + assertTrue(VehiclePlateSupport.isValidPlateNo("桂a32523")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A·32523")); + } + + @Test + @DisplayName("车牌格式:空值、乱码、位数不对一律不合法") + void invalidPlateFormats() { + assertFalse(VehiclePlateSupport.isValidPlateNo(null)); + assertFalse(VehiclePlateSupport.isValidPlateNo("")); + assertFalse(VehiclePlateSupport.isValidPlateNo("乱写的车牌")); + assertFalse(VehiclePlateSupport.isValidPlateNo("A32523")); + assertFalse(VehiclePlateSupport.isValidPlateNo("桂A325")); + } + + @Test + @DisplayName("空车牌列表不产生任何条件(避免 IN () 语法错误)") + void emptyPlateListAddsNoCondition() { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + VehiclePlateSupport.applyPlateMatch(wrapper, List.of()); + assertEquals("", wrapper.getTargetSql().trim()); + VehiclePlateSupport.applyPlateMatch(wrapper, null); + assertEquals("", wrapper.getTargetSql().trim()); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java new file mode 100644 index 00000000..fa3334c2 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -0,0 +1,315 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 打卡门禁:异常门禁(节点区间 + 在途运单级)与顺序门禁(在途豁免)。 + *

+ * 这里的口径会同时决定司机端卡片的禁用态与后端提交接口的校验, + * 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与 + * {@link WaybillProcessSupport#unfinishedPrerequisiteKeys}, + * 所以断言下在它们身上就覆盖了两条链路。 + *

+ * 同域还放着异常标签的组装规则:哪个节点该挂「整单异常」、哪些异常算与某节点相关。 + */ +class WaybillProcessSupportTest { + + /** 与线上「石灰石包头运输项目」实际下发的过程配置同序:在途排在发货之后、到货之前 */ + private static final String TRANSPORT_JSON = """ + [ + {"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"arrive_scene","name":"到场","type":"punch","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","type":"cargo","enabled":true,"punch":true}, + {"key":"unload","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} + ] + """; + + private static Set keys(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } + + // ------------------------------------------------------------ 异常门禁 + + @Test + @DisplayName("无未了结异常时谁都不阻断") + void withoutUnresolvedExceptionNothingIsBlocked() { + assertTrue(WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Collections.emptyList()).isEmpty()); + } + + @Test + @DisplayName("普通节点:异常所在节点及其后节点阻断,之前的不受影响") + void unresolvedExceptionBlocksNodeAndItsSuccessors() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("arrival")); + + assertTrue(blocked.containsAll(keys("arrival", "unload", "sign")), "异常所在节点及其后节点都要锁"); + assertFalse(blocked.contains("arrive_scene"), "异常之前的节点不该被锁"); + assertFalse(blocked.contains("load")); + assertFalse(blocked.contains("dispatch")); + // 在途是运单级的例外:异常落在它之后也锁,见 transitIsBlockedByExceptionOnLaterNode + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("在途:异常落在它之后的「卸货」时同样阻断——门禁是运单级,不看异常在哪个节点") + void transitIsBlockedByExceptionOnLaterNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload")); + + assertTrue(blocked.contains("transit"), + "运单还有未了结异常时,在途就不能打卡;按区间算会漏掉「异常在在途之后」这种最常见的情形"); + assertEquals(keys("transit", "unload", "sign"), blocked); + } + + @Test + @DisplayName("在途:异常落在它之前的「装货」时也阻断") + void transitIsBlockedByExceptionOnEarlierNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("load")); + + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("未指明节点的异常(全局入口上报)阻断全部打卡节点,含在途") + void exceptionWithoutNodeBlocksEverything() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Arrays.asList("")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("多节点上报异常时取最早的那个起算,在途仍被阻断") + void earliestExceptionDecidesTheRange() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload", "arrive_scene")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("配置里没有在途节点时不凭空造一个 key") + void withoutTransitNodeNothingIsInvented() { + String noTransit = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + assertEquals(keys("load", "unload"), WaybillProcessSupport.blockedNodeKeys(noTransit, List.of("load"))); + } + + // ------------------------------------------------------------ 顺序门禁 + + @Test + @DisplayName("顺序门禁:前面还有打卡节点没打时返回它们,在途不作为前置") + void unfinishedPrerequisitesIgnoreTransit() { + Set punched = keys("arrive_scene", "load", "dispatch"); + + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, punched, "unload"); + + assertEquals(keys("arrival"), unfinished, + "「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)"); + } + + @Test + @DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集") + void transitIsExemptFromOrderGate() { + assertTrue( + WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, Collections.emptySet(), "transit").isEmpty(), + "在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了"); + } + + // ------------------------------------------------------------ 异常标签与相关异常 + + @Test + @DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点") + void relatesToNodeCoversWaybillLevelForEveryNode() { + assertTrue(WaybillProcessSupport.relatesToNode("load", null), + "整单异常不归属任何节点,对每个节点都算相关"); + assertTrue(WaybillProcessSupport.relatesToNode("load", "")); + assertTrue(WaybillProcessSupport.relatesToNode("load", "load")); + assertFalse(WaybillProcessSupport.relatesToNode("sign", "load"), + "装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事"); + } + + @Test + @DisplayName("节点编码忽略大小写,两侧空白不影响判定") + void relatesToNodeIgnoresCaseAndBlank() { + assertTrue(WaybillProcessSupport.relatesToNode("load", "LOAD")); + assertTrue(WaybillProcessSupport.relatesToNode(" load ", " LOAD ")); + assertFalse(WaybillProcessSupport.relatesToNode("load", "unload")); + } + + @Test + @DisplayName("整单异常的判定只看节点编码是否为空") + void waybillLevelExceptionFlag() { + assertTrue(WaybillProcessSupport.isWaybillLevelException(null)); + assertTrue(WaybillProcessSupport.isWaybillLevelException("")); + assertTrue(WaybillProcessSupport.isWaybillLevelException(" ")); + assertFalse(WaybillProcessSupport.isWaybillLevelException("load")); + } + + @Test + @DisplayName("节点编码相同判定:忽略大小写与两侧空白,空与空不算相等") + void sameNodeCodeIgnoresCaseAndBlank() { + assertTrue(WaybillProcessSupport.sameNodeCode("load", "LOAD")); + assertTrue(WaybillProcessSupport.sameNodeCode(" load ", "load")); + assertFalse(WaybillProcessSupport.sameNodeCode("load", "unload")); + assertFalse(WaybillProcessSupport.sameNodeCode("load", ""), "节点编码为空表示整单,不等于任何节点"); + assertFalse(WaybillProcessSupport.sameNodeCode("", ""), "空与空不算相等"); + assertFalse(WaybillProcessSupport.sameNodeCode(null, null)); + } + + // ------------------------------------------------------------ 到货/卸货/签收 + + @Test + @DisplayName("到货/卸货/签收:key 或中文名任一命中即可,两者都为空不算") + void deliveryNodeMatchesKeyOrName() { + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", ""), "只有 key 也要认,别把中文名当必要条件"); + assertTrue(WaybillProcessSupport.isDeliveryNode("", "签收"), "只有中文名也要认——key 可能被改成自定义编码"); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货")); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途")); + assertFalse(WaybillProcessSupport.isDeliveryNode("", "")); + assertFalse(WaybillProcessSupport.isDeliveryNode(null, null)); + } + + // ------------------------------------------------------------ 末位司机打卡节点 + + /** 末位是「到货」,其后没有打卡节点 */ + private static final String ARRIVAL_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","enabled":true,"punch":true}] + """; + + /** 末位是「卸货」 */ + private static final String UNLOAD_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + /** 末位是「在途」 */ + private static final String TRANSIT_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}] + """; + + /** 末位是「发货」 */ + private static final String DISPATCH_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("末位打卡节点取「司机可打卡节点」的末位:接单与回单就算启用也不算") + void lastDriverPunchNodeSkipsAcceptAndReturn() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign"), + "回单启用在配置里排最末,但回单不打卡,末位打卡节点仍是签收"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "unload")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "arrival")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "return")); + } + + @Test + @DisplayName("末位是签收 / 到货 / 卸货才同时成立「末位」与「到货卸货签收」;末位是在途 / 发货只满足前者") + void lastNodeIsDeliveryOnlyForDeliveryTypes() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign") + && WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(ARRIVAL_LAST_JSON, "arrival") + && WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(UNLOAD_LAST_JSON, "unload") + && WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSIT_LAST_JSON, "transit"), + "在途排在末位时它确实是末位打卡节点"); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"), "在途不是可完成节点"); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "dispatch")); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"), "发货不是可完成节点"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "unload"), + "该配置里没有卸货节点,不能因为编码眼熟就认下"); + } + + @Test + @DisplayName("没有可打卡节点(或没有过程配置)时,谁都不是末位") + void lastDriverPunchNodeWithoutPunchNodes() { + String onlyAcceptAndReturn = """ + [{"key":"accept","name":"接单","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true}] + """; + + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "accept")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "return")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode("", "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(null, "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, null)); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "")); + } + + // ------------------------------------------------------------ 回单是否无需确认完成 + + /** 回单启用且配置为「无需确认完成」 */ + private static final String RETURN_NO_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}] + """; + + /** 回单启用且配置为「需确认完成」 */ + private static final String RETURN_NEED_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 回单未启用,但 confirmMode 写着「需确认完成」 */ + private static final String RETURN_DISABLED_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":false,"punch":true,"confirmMode":"yes"}] + """; + + /** 配置里根本没有回单节点 */ + private static final String NO_RETURN_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("回单启用时要看 confirmMode:只有「无需确认完成」才成立") + void enabledReturnNeedsConfirmMode() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NO_CONFIRM_JSON)); + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NEED_CONFIRM_JSON), + "回单需要确认完成时,打完签收不能算整单完成"); + } + + @Test + @DisplayName("回单未启用、或配置里没有回单节点,一律视为无需确认完成") + void absentOrDisabledReturnNeedsNoConfirmation() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_DISABLED_JSON), + "回单未启用时 confirmMode 写着需确认也不算数:没启用的环节不会有人来确认"); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(NO_RETURN_JSON)); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation("")); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(null)); + } + + @Test + @DisplayName("回单启用但 confirmMode 缺失时按「需确认完成」算") + void enabledReturnWithoutConfirmModeNeedsConfirmation() { + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(TRANSPORT_JSON), + "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); + } + +} diff --git a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java index 4f6027c0..e157641b 100644 --- a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java +++ b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java @@ -31,7 +31,35 @@ public class OAConvertConstant { */ public static final Long ROOT_PARENT_ID = 0L; /** - * 同步人员时挂载的顶级组织名称 + * OA 同步挂载的本地顶级组织主键(集团) */ - public static final String ROOT_COMPANY_NAME = "桂物物流集团"; + public static final Long OA_SYNC_ROOT_DEPT_ID = 135L; + /** + * OA 同步挂载的本地顶级组织默认名称(集团;已存在时不覆盖原名称,除非误写成外部组织) + */ + public static final String OA_SYNC_ROOT_DEPT_NAME = "桂物物流集团"; + /** + * OA 同步挂载的本地顶级组织编码(集团) + */ + public static final String OA_SYNC_ROOT_DEPT_CODE = "GWWL"; + /** + * 承运商等业务使用的外部组织顶级名称(与集团顶级并存) + */ + public static final String EXTERNAL_ORG_NAME = "外部组织"; + /** + * 组织编码分段数字位数(不足补零) + */ + public static final int DEPT_CODE_SEGMENT_WIDTH = 3; + /** + * 历史人员同步创建的伪顶级编码,同步时需清理 + */ + public static final String LEGACY_ROOT_DEPT_CODE = "OACROOT"; + /** + * 历史人员同步创建的伪顶级名称(与集团同名时仅清理非 id=135 的记录) + */ + public static final String LEGACY_ROOT_DEPT_NAME = "桂物物流集团"; + /** + * 同步人员时挂载的顶级组织名称(兼容旧引用) + */ + public static final String ROOT_COMPANY_NAME = OA_SYNC_ROOT_DEPT_NAME; } diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index dfb6220c..a444dde6 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -156,6 +156,15 @@ baidu: request-timeout: 30s token-refresh-advance: 1m +# 对象存储(blade-file 分片上传 + blade-transport 凭证读写共用) +# endpoint:强烈建议内网 MinIO(http://IP:19000);若只能走网关可填 https://tms-prod.xinghy56.com/oss(代码会自动处理 /oss) +# transform-endpoint:浏览器预签名外网地址 +file: + storage: + minio: + # endpoint: http://内网MinIO地址:端口 + transform-endpoint: https://tms-prod.xinghy56.com/oss + powerjob: worker: server-address: 172.16.203.228:7700 diff --git a/doc/nacos/routes/blade-gateway-dev.json b/doc/nacos/routes/blade-gateway-dev.json index d68db7a8..dce7bc48 100644 --- a/doc/nacos/routes/blade-gateway-dev.json +++ b/doc/nacos/routes/blade-gateway-dev.json @@ -49,6 +49,28 @@ ], "uri": "lb://blade-system" }, + { + "id": "resource-route", + "order": 0, + "predicates": [ + { + "name": "Path", + "args": { + "pattern": "/blade-resource/**" + } + } + ], + "filters": [ + { + "name": "RewritePath", + "args": { + "regexp": "/blade-resource/(?.*)", + "replacement": "/$\\{segment}" + } + } + ], + "uri": "http://127.0.0.1:8010" + }, { "id": "example-route", "order": 0, diff --git a/doc/sql/bladex/bladex.kingbase.all.create.sql b/doc/sql/bladex/bladex.kingbase.all.create.sql index b7bf2e51..c1c00b5e 100644 --- a/doc/sql/bladex/bladex.kingbase.all.create.sql +++ b/doc/sql/bladex/bladex.kingbase.all.create.sql @@ -2442,7 +2442,7 @@ CREATE TABLE "blade_railway_station" ( "id" int8 NOT NULL, "code" varchar(20) NOT NULL, "tmis_code" varchar(5) NOT NULL, - "telegraph_code" varchar(3) NOT NULL, + "telegraph_code" varchar(3) NULL, "name" varchar(100) NOT NULL, "railway_line" varchar(100), "province_code" varchar(12), diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 9da80698..8a7cb64e 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -304,6 +304,7 @@ CREATE TABLE `blade_dept` ( `full_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门全称', `dept_code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织编码', `kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '金蝶部门代码', + `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '合同编码前缀', `short_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织简称', `pinyin_mnemonic` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '拼音助记码', `mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码', @@ -1550,7 +1551,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint NOT NULL COMMENT '主键', `code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '编码', `tmis_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车站名称', `railway_line` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属省份编码', diff --git a/doc/sql/bladex/bladex.postgres.all.create.sql b/doc/sql/bladex/bladex.postgres.all.create.sql index b7bf2e51..c1c00b5e 100644 --- a/doc/sql/bladex/bladex.postgres.all.create.sql +++ b/doc/sql/bladex/bladex.postgres.all.create.sql @@ -2442,7 +2442,7 @@ CREATE TABLE "blade_railway_station" ( "id" int8 NOT NULL, "code" varchar(20) NOT NULL, "tmis_code" varchar(5) NOT NULL, - "telegraph_code" varchar(3) NOT NULL, + "telegraph_code" varchar(3) NULL, "name" varchar(100) NOT NULL, "railway_line" varchar(100), "province_code" varchar(12), diff --git a/doc/sql/bladex/bladex.sqlserver.all.create.sql b/doc/sql/bladex/bladex.sqlserver.all.create.sql index 632f8014..69b7edd4 100644 --- a/doc/sql/bladex/bladex.sqlserver.all.create.sql +++ b/doc/sql/bladex/bladex.sqlserver.all.create.sql @@ -7592,7 +7592,7 @@ CREATE TABLE [dbo].[blade_railway_station] ( [id] bigint NOT NULL, [code] nvarchar(20) NOT NULL, [tmis_code] nvarchar(5) NOT NULL, - [telegraph_code] nvarchar(3) NOT NULL, + [telegraph_code] nvarchar(3) NULL, [name] nvarchar(100) NOT NULL, [railway_line] nvarchar(100) NULL, [province_code] nvarchar(12) NULL, diff --git a/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql b/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql new file mode 100644 index 00000000..8ebe38ed --- /dev/null +++ b/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql @@ -0,0 +1,3 @@ +-- 合同管理新增申请日期:正式合同提交时写入当天日期 +ALTER TABLE `blade_contract_manage` + ADD COLUMN `apply_date` date DEFAULT NULL COMMENT '申请日期(提交正式合同时写入)' AFTER `sign_date`; diff --git a/doc/sql/transport/blade_contract_manage_main_content_20261009.sql b/doc/sql/transport/blade_contract_manage_main_content_20261009.sql new file mode 100644 index 00000000..ae0d144c --- /dev/null +++ b/doc/sql/transport/blade_contract_manage_main_content_20261009.sql @@ -0,0 +1,3 @@ +-- 合同管理新增合同主要内容(非必填,最多1000字) +ALTER TABLE `blade_contract_manage` + ADD COLUMN `main_content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同主要内容' AFTER `terminate_reason`; diff --git a/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql b/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql new file mode 100644 index 00000000..781b00b1 --- /dev/null +++ b/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql @@ -0,0 +1,3 @@ +-- 组织机构新增合同编码前缀:合同编号 = 前缀(年份)三位流水号,流水按部门独立 +ALTER TABLE `blade_dept` + ADD COLUMN `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同编码前缀' AFTER `kingdee_dept_code`; diff --git a/doc/sql/transport/blade_dept_oa_modified_20261010.sql b/doc/sql/transport/blade_dept_oa_modified_20261010.sql new file mode 100644 index 00000000..97960aff --- /dev/null +++ b/doc/sql/transport/blade_dept_oa_modified_20261010.sql @@ -0,0 +1,4 @@ +-- 机构表:记录 OA 的 modified(上次更新时间),用于增量同步比对 + +ALTER TABLE `blade_dept` + ADD COLUMN `oa_modified` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上次更新时间(modified)' AFTER `oa_sup_sub_com_id`; diff --git a/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql b/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql new file mode 100644 index 00000000..a9a03e6c --- /dev/null +++ b/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql @@ -0,0 +1,9 @@ +-- 合同附件类型字典:合同文件 → 用印材料 +-- 业务字典 code = contract_attachment_type + +UPDATE blade_dict_biz +SET dict_key = '用印材料', + dict_value = '用印材料' +WHERE code = 'contract_attachment_type' + AND is_deleted = 0 + AND (dict_key = '合同文件' OR dict_value = '合同文件'); diff --git a/doc/sql/transport/blade_exception_disposal_20260812.sql b/doc/sql/transport/blade_exception_disposal_20260812.sql index 4b2c1769..1592b95d 100644 --- a/doc/sql/transport/blade_exception_disposal_20260812.sql +++ b/doc/sql/transport/blade_exception_disposal_20260812.sql @@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', `waybill_id` bigint(20) DEFAULT NULL COMMENT '运单ID', `waybill_no` varchar(100) DEFAULT NULL COMMENT '运单号', + `node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)', + `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)', `loading_or_master_no` varchar(100) DEFAULT NULL COMMENT '配载单号/总单号', `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_name` varchar(100) DEFAULT NULL COMMENT '项目', @@ -34,7 +36,7 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal` ( KEY `idx_exception_disposal_status` (`disposal_status`) USING BTREE, KEY `idx_exception_disposal_report_time` (`report_time`) USING BTREE, KEY `idx_exception_disposal_create_time` (`create_time`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常处置'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常处置'; CREATE TABLE IF NOT EXISTS `blade_exception_disposal_follow_record` ( `id` bigint(20) NOT NULL COMMENT '主键', @@ -48,13 +50,14 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal_follow_record` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', `disposal_id` bigint(20) NOT NULL COMMENT '异常处置ID', `follow_content` varchar(500) NOT NULL COMMENT '跟进说明', + `attachments_json` text DEFAULT NULL COMMENT '附件JSON', `follow_user` bigint(20) DEFAULT NULL COMMENT '跟进人', `follow_user_name` varchar(100) DEFAULT NULL COMMENT '跟进人姓名', `follow_time` datetime DEFAULT NULL COMMENT '跟进时间', PRIMARY KEY (`id`) USING BTREE, KEY `idx_exception_follow_disposal` (`disposal_id`) USING BTREE, KEY `idx_exception_follow_time` (`follow_time`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常处置跟进记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常处置跟进记录'; INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES diff --git a/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql new file mode 100644 index 00000000..51ad9dfb --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql @@ -0,0 +1,33 @@ +-- 异常处置新增编辑权限码 +-- 日期:2026-10-10 + +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000002006,2090000000000002001,'exception_disposal_edit','编辑','exception_disposal_edit','','',5,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `parent_id`=VALUES(`parent_id`), + `name`=VALUES(`name`), + `path`=VALUES(`path`), + `sort`=VALUES(`sort`), + `is_deleted`=0; + +-- 授权给超级管理员角色。 +-- 不能按「持有 exception_disposal 菜单的角色」批量授权:司机角色同样持有该菜单, +-- 会被一并授予编辑权——而编辑是运管员才有的操作。 +-- 注意:运管角色当前并未持有 exception_disposal 菜单,故此处不涉及它; +-- 若日后把异常处置开放给运管,需另行授予本编辑码。 +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2090000000000002006 + (r.`id` % 1000), 2090000000000002006, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2090000000000002006 AND x.`role_id` = r.`id` + ); + +-- 幂等收口:本编辑码只应挂在超级管理员上,其余角色一律收回。 +-- 上面的 SELECT 是按授权规则下发,这条是按同一规则回收,二者合起来可重复执行。 +DELETE FROM `blade_role_menu` +WHERE `menu_id` = 2090000000000002006 + AND `role_id` NOT IN (SELECT `id` FROM `blade_role` WHERE `role_alias` = 'administrator'); diff --git a/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql b/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql new file mode 100644 index 00000000..84f6cccb --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql @@ -0,0 +1,27 @@ +-- 异常处置跟进记录补充附件 +-- 日期:2026-10-10 +-- +-- 附件以 JSON 数组存放([{"name":"...","url":"..."}]),与本仓库其它附件字段一致。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_blade_exception_disposal_follow_attachments_20261010`$$ +CREATE PROCEDURE `upgrade_blade_exception_disposal_follow_attachments_20261010`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal_follow_record' + AND column_name = 'attachments_json') THEN + ALTER TABLE `blade_exception_disposal_follow_record` + ADD COLUMN `attachments_json` text DEFAULT NULL COMMENT '附件JSON' + AFTER `follow_content`; + END IF; +END$$ + +CALL `upgrade_blade_exception_disposal_follow_attachments_20261010`()$$ +DROP PROCEDURE `upgrade_blade_exception_disposal_follow_attachments_20261010`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql new file mode 100644 index 00000000..ca09481d --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql @@ -0,0 +1,37 @@ +-- 异常处置增加过程节点归属 +-- 日期:2026-10-05 +-- 节点归属用于打卡门禁:某节点有未了结异常时,该节点及其后的节点不可打卡。 +-- node_code 为空表示未指明节点(全局入口上报),此类异常阻断该运单所有尚未打卡的节点。 +-- node_name 在上报时复制,此后不随过程配置变化,与车牌、项目、承运商等展示字段同一口径。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_blade_exception_disposal_node_code_20261005`$$ +CREATE PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal' + AND column_name = 'node_code') THEN + ALTER TABLE `blade_exception_disposal` + ADD COLUMN `node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)' + AFTER `waybill_no`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal' + AND column_name = 'node_name') THEN + ALTER TABLE `blade_exception_disposal` + ADD COLUMN `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)' + AFTER `node_code`; + END IF; +END$$ + +CALL `upgrade_blade_exception_disposal_node_code_20261005`()$$ +DROP PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_mp_user_20261012.sql b/doc/sql/transport/blade_mp_user_20261012.sql new file mode 100644 index 00000000..9d34d49c --- /dev/null +++ b/doc/sql/transport/blade_mp_user_20261012.sql @@ -0,0 +1,45 @@ +-- ---------------------------- +-- 小程序用户表(与 blade_user 分离,仅用于小程序登录) +-- 身份 identities:逗号分隔,当前支持 driver(司机)、admin(管理员),可同时拥有,后续可扩展 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `blade_mp_user` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '000000' COMMENT '租户ID', + `code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户编号', + `account` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '账号(通常为手机号)', + `password` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '密码(小程序登录可不使用)', + `name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '昵称', + `real_name` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '真名', + `avatar` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像', + `email` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', + `phone` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机', + `birthday` datetime NULL DEFAULT NULL COMMENT '生日', + `sex` int NULL DEFAULT NULL COMMENT '性别', + `openid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '微信小程序 openid', + `identities` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '小程序身份,逗号分隔:driver,admin', + `dept_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id', + `post_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '岗位id', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注', + `create_user` bigint NULL DEFAULT NULL COMMENT '创建人', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `update_user` bigint NULL DEFAULT NULL COMMENT '修改人', + `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间', + `status` int NULL DEFAULT 1 COMMENT '状态', + `is_deleted` int NULL DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_mp_user_tenant_phone` (`tenant_id`, `phone`) USING BTREE, + UNIQUE KEY `uk_mp_user_tenant_openid` (`tenant_id`, `openid`) USING BTREE, + KEY `idx_mp_user_account` (`tenant_id`, `account`) USING BTREE, + KEY `idx_mp_user_identities` (`identities`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '小程序用户表'; + +-- 司机表 user_id 语义变更为关联小程序用户 +ALTER TABLE `blade_transport_driver` + MODIFY COLUMN `user_id` bigint(20) DEFAULT NULL COMMENT '关联小程序用户ID(blade_mp_user.id)'; + +-- 示例:创建小程序管理员(调度端),identities 可同时包含 driver,admin +-- INSERT INTO `blade_mp_user` +-- (`id`, `tenant_id`, `account`, `name`, `real_name`, `phone`, `identities`, `create_time`, `status`, `is_deleted`) +-- VALUES +-- (2091900000000000001, '000000', '13800000000', '调度管理员', '调度管理员', '13800000000', 'admin', NOW(), 1, 0); diff --git a/doc/sql/transport/blade_mp_user_menu_20261012.sql b/doc/sql/transport/blade_mp_user_menu_20261012.sql new file mode 100644 index 00000000..c918cd6b --- /dev/null +++ b/doc/sql/transport/blade_mp_user_menu_20261012.sql @@ -0,0 +1,90 @@ +-- ---------------------------- +-- 小程序用户管理菜单(挂在系统管理下) +-- 日期:2026-10-12 +-- ---------------------------- + +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2091901200000000101,1123598815738675203,'mp_user','小程序用户','menu','/system/mp-user','iconfont icon-yonghu',3,1,0,1,NULL,'小程序用户管理',0), + (2091901200000000102,2091901200000000101,'mp_user_add','新增','add','','',1,2,0,1,NULL,'',0), + (2091901200000000103,2091901200000000101,'mp_user_edit','编辑','edit','','',2,2,0,1,NULL,'',0), + (2091901200000000104,2091901200000000101,'mp_user_delete','删除','delete','','',3,2,0,1,NULL,'',0), + (2091901200000000105,2091901200000000101,'mp_user_view','查看','view','','',4,2,0,1,NULL,'',0), + (2091901200000000106,2091901200000000101,'mp_user_status','修改状态','status','','',5,2,0,1,NULL,'',0), + (2091901200000000107,2091901200000000101,'mp_user_reset','密码重置','reset','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `parent_id`=VALUES(`parent_id`), + `code`=VALUES(`code`), + `name`=VALUES(`name`), + `alias`=VALUES(`alias`), + `path`=VALUES(`path`), + `source`=VALUES(`source`), + `sort`=VALUES(`sort`), + `category`=VALUES(`category`), + `remark`=VALUES(`remark`), + `is_deleted`=0; + +-- 授权给超级管理员角色(幂等) +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000101 + (r.`id` % 1000), 2091901200000000101, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000101 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000102 + (r.`id` % 1000), 2091901200000000102, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000102 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000103 + (r.`id` % 1000), 2091901200000000103, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000103 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000104 + (r.`id` % 1000), 2091901200000000104, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000104 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000105 + (r.`id` % 1000), 2091901200000000105, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000105 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000106 + (r.`id` % 1000), 2091901200000000106, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000106 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000107 + (r.`id` % 1000), 2091901200000000107, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000107 AND x.`role_id` = r.`id` + ); diff --git a/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql b/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql new file mode 100644 index 00000000..9f3063b5 --- /dev/null +++ b/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql @@ -0,0 +1,61 @@ +-- ---------------------------- +-- 将已关联 blade_user 的司机迁移到 blade_mp_user(可选,按环境执行) +-- 注意:执行前请先执行 blade_mp_user_20261012.sql 建表 +-- 策略:小程序用户主键使用司机表 id(避免与 blade_user.id 冲突导致令牌刷新串用户) +-- 迁移后回写 driver.user_id;原 blade_user 账号不会自动删除,请人工确认后再清理 +-- 调度端管理员需另行在 blade_mp_user 中维护 identities 含 admin 的账号 +-- ---------------------------- + +-- 1) 按司机写入小程序用户 +INSERT INTO `blade_mp_user` ( + `id`, `tenant_id`, `account`, `name`, `real_name`, `phone`, `sex`, + `openid`, `identities`, `dept_id`, + `create_user`, `create_dept`, `create_time`, `update_user`, `update_time`, + `status`, `is_deleted` +) +SELECT + d.`id` AS `id`, + IFNULL(d.`tenant_id`, '000000') AS `tenant_id`, + d.`mobile` AS `account`, + d.`driver_name` AS `name`, + d.`driver_name` AS `real_name`, + d.`mobile` AS `phone`, + CASE + WHEN d.`gender` IN ('男', '1') THEN 1 + WHEN d.`gender` IN ('女', '2') THEN 2 + ELSE NULL + END AS `sex`, + oauth.`uuid` AS `openid`, + 'driver' AS `identities`, + u.`dept_id` AS `dept_id`, + d.`create_user`, + d.`create_dept`, + IFNULL(d.`create_time`, NOW()) AS `create_time`, + d.`update_user`, + IFNULL(d.`update_time`, NOW()) AS `update_time`, + IFNULL(d.`status`, 1) AS `status`, + 0 AS `is_deleted` +FROM `blade_transport_driver` d +LEFT JOIN `blade_user` u ON u.`id` = d.`user_id` AND u.`is_deleted` = 0 +LEFT JOIN `blade_user_oauth` oauth + ON oauth.`user_id` = d.`user_id` + AND oauth.`source` = 'WECHAT_MINI' + AND oauth.`is_deleted` = 0 +WHERE d.`is_deleted` = 0 + AND d.`mobile` IS NOT NULL + AND d.`mobile` <> '' + AND NOT EXISTS ( + SELECT 1 FROM `blade_mp_user` m + WHERE m.`id` = d.`id` + OR (m.`tenant_id` = IFNULL(d.`tenant_id`, '000000') AND m.`phone` = d.`mobile` AND m.`is_deleted` = 0) + ); + +-- 2) 回写司机关联的小程序用户ID +UPDATE `blade_transport_driver` d +INNER JOIN `blade_mp_user` m + ON m.`id` = d.`id` + OR (m.`tenant_id` = IFNULL(d.`tenant_id`, '000000') AND m.`phone` = d.`mobile` AND m.`is_deleted` = 0) +SET d.`user_id` = m.`id` +WHERE d.`is_deleted` = 0 + AND d.`mobile` IS NOT NULL + AND d.`mobile` <> ''; diff --git a/doc/sql/transport/blade_process_config_cargo_types_20261005.sql b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql new file mode 100644 index 00000000..b03a51bc --- /dev/null +++ b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql @@ -0,0 +1,19 @@ +-- 订正过程配置中「上传货量为是、货量类型为空」的脏配置 +-- 日期:2026-10-05 +-- +-- 成因:过程配置页的「到场」「回单」两个节点模板缺 supportCargo 字段,货量开关 +-- 在界面上不渲染,导致这两个节点被写成 uploadCargo=true 且 cargoTypes 恒为空。 +-- 语义:「是否需要采集货量」以已勾选的货量类型为准——类型为空即不采集, +-- 不存在「没配类型所以三项全采集」。 +-- 影响:订正后这些节点在小程序不再显示货量表单。因司机端解析过程配置时优先取 +-- 启用中的活配置,本次订正会同时改变所有引用该项目的运单(含历史运单)的呈现。 +-- 范围:只订正过程配置表,不修改 blade_waybill.process_json 快照列(那是历史留痕)。 +-- 幂等:执行后目标子串不再存在,重复执行匹配 0 行。 + +UPDATE `blade_process_config` +SET `node_config_json` = REPLACE( + `node_config_json`, + '"uploadCargo":true,"cargoTypes":[]', + '"uploadCargo":false,"cargoTypes":[]') +WHERE `is_deleted` = 0 + AND `node_config_json` LIKE '%"uploadCargo":true,"cargoTypes":[]%'; diff --git a/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql new file mode 100644 index 00000000..b2d5d222 --- /dev/null +++ b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql @@ -0,0 +1,4 @@ +-- 平台公司支持多选:ID 逗号分隔,名称顿号分隔 +ALTER TABLE `blade_project_apply` + MODIFY COLUMN `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + MODIFY COLUMN `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔'; diff --git a/doc/sql/transport/blade_project_contract_management.sql b/doc/sql/transport/blade_project_contract_management.sql index 2d720698..59890b1b 100644 --- a/doc/sql/transport/blade_project_contract_management.sql +++ b/doc/sql/transport/blade_project_contract_management.sql @@ -18,8 +18,8 @@ CREATE TABLE `blade_project_apply` ( `project_type` varchar(50) DEFAULT NULL COMMENT '项目类型', `business_dept_id` bigint(20) DEFAULT NULL COMMENT '业务部门ID', `business_dept_name` varchar(100) DEFAULT NULL COMMENT '业务部门', - `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', - `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', + `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', `project_source` varchar(50) DEFAULT NULL COMMENT '项目由来', `source_remark` varchar(500) DEFAULT NULL COMMENT '项目由来说明', `fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', @@ -80,8 +80,8 @@ CREATE TABLE `blade_temporary_credit_limit` ( `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编号', `project_name` varchar(100) DEFAULT NULL COMMENT '项目名称', - `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', - `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', + `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', `project_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', `used_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '已使用项目资金额度(万元)', `remaining_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '剩余项目资金使用额度(万元)', @@ -119,8 +119,8 @@ CREATE TABLE `blade_contract_manage` ( `contract_name` varchar(100) DEFAULT NULL COMMENT '合同名称', `project_id` bigint(20) DEFAULT NULL COMMENT '所属项目ID', `project_name` varchar(100) DEFAULT NULL COMMENT '所属项目', - `organization_id` bigint(20) DEFAULT NULL COMMENT '所属组织ID', - `organization_name` varchar(100) DEFAULT NULL COMMENT '所属组织', + `organization_id` bigint(20) DEFAULT NULL COMMENT '合同负责部门ID', + `organization_name` varchar(100) DEFAULT NULL COMMENT '合同负责部门', `contract_category` varchar(50) DEFAULT NULL COMMENT '合同类别', `sign_type` varchar(50) DEFAULT NULL COMMENT '签约类型', `party_a` varchar(200) DEFAULT NULL COMMENT '甲方', @@ -132,6 +132,7 @@ CREATE TABLE `blade_contract_manage` ( `handler_user_id` bigint(20) DEFAULT NULL COMMENT '经办人ID', `handler_user_name` varchar(100) DEFAULT NULL COMMENT '经办人', `sign_date` date DEFAULT NULL COMMENT '签订日期', + `apply_date` date DEFAULT NULL COMMENT '申请日期(提交正式合同时写入)', `settlement_mode` varchar(100) DEFAULT NULL COMMENT '结算方式', `contract_format` varchar(50) DEFAULT NULL COMMENT '合同格式', `legal_seal_flag` int(11) DEFAULT '0' COMMENT '是否需要加盖法人章', @@ -160,6 +161,7 @@ CREATE TABLE `blade_contract_manage` ( `change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因', `change_attachments_json` text DEFAULT NULL COMMENT '待审批变更附件JSON', `terminate_reason` varchar(500) DEFAULT NULL COMMENT '终止原因', + `main_content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同主要内容', `remark` varchar(2000) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`) USING BTREE, KEY `idx_contract_project` (`project_id`) USING BTREE, diff --git a/doc/sql/transport/blade_railway_station.sql b/doc/sql/transport/blade_railway_station.sql index 17cf66c5..dc5e4979 100644 --- a/doc/sql/transport/blade_railway_station.sql +++ b/doc/sql/transport/blade_railway_station.sql @@ -6,7 +6,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint NOT NULL COMMENT '主键', `code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '编码', `tmis_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车站名称', `railway_line` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属省份编码', diff --git a/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql b/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql new file mode 100644 index 00000000..7977f448 --- /dev/null +++ b/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql @@ -0,0 +1,5 @@ +-- 铁路车站电报略码改为非必填(有值时仍保持唯一) +-- 执行前请先备份数据库;如字段已是可空,可跳过本脚本。 + +ALTER TABLE `blade_railway_station` + MODIFY COLUMN `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码'; diff --git a/doc/sql/transport/blade_transport_cert_backfill_20261007.sql b/doc/sql/transport/blade_transport_cert_backfill_20261007.sql new file mode 100644 index 00000000..91dd544e --- /dev/null +++ b/doc/sql/transport/blade_transport_cert_backfill_20261007.sql @@ -0,0 +1,277 @@ +-- ---------------------------- +-- 认证:存量司机 / 车辆补生效快照 +-- 写业务数据。执行前必须先建备份表(第 0 节),执行后必须跑第 3 节校验。可重复执行。 +-- ---------------------------- +-- +-- 为什么必须补: +-- 「能否接单」在运营状态之上叠加了认证门槛,判据是生效快照指针 +-- blade_transport_driver.effective_cert_record_id 与 +-- blade_transport_vehicle.effective_cert_record_id。这两列与门槛一起新增, +-- 新增时对存量对象一律为 NULL;门槛默认开启,于是存量司机与车辆全部被判成 +-- 「未认证」,接单被直接拒绝。它们在此之前一直正常承运,可用性是既成事实, +-- 不该因为换了判定方式就丢掉。 +-- +-- 处置口径:给尚无生效快照的存量对象补一条「已通过」记录,并把指针指过去。 +-- 这是自动认可、不是任何人的审核行为,故 audit_user / audit_time 一律留空, +-- 不凭空编造审核留痕。 +-- +-- 快照键名必须与 PersonCertSubmitRequest / VehicleCertSubmitRequest 完全一致。 +-- 键名对不上不会报错、只会静默丢值:司机端「重新认证」表单会缺字段, +-- 审核详情会把每个字段都报成「已变更」。改 DTO 字段名时务必同步本脚本。 +-- +-- 司机的从业资格证有效期起(qualificationValidStart)在快照里没有来源: +-- blade_transport_driver 没有对应列,管理端的同名录入项也是只收不存。 +-- 此处同样不造值,该键留空。 +-- +-- 司机快照里没有 mobile:审核列表的司机姓名/手机号取的是司机档案当前值, +-- 不是提交时的快照值,故无需写进快照。 +-- +-- 提交司机(submit_user)为什么一起补: +-- 审核列表的「司机姓名 / 司机手机号」取数路径是 submit_user -> 系统账号 -> 司机档案。 +-- submit_user 为空,这两列就永远空白,审核队列「找得到人、联系得上」的价值就没了。 +-- 车辆认证记录本身不含司机信息,其提交司机取「该车当前绑定的司机」—— +-- 车辆认证的提交链路强制校验只能操作本人绑定的车辆,故真实提交的车辆认证记录, +-- 其提交司机就是这辆车的绑定司机。一条车牌有多个绑定司机时 +-- (driving_vehicle 是逗号分隔的文本列、无唯一索引),取司机档案 update_time 最新者: +-- 库里没有绑定时间戳,这是可用的最好代理指标。 +-- 解析不到系统账号的(该司机根本没有 blade_user),留 NULL,不编造。 +-- +-- 号段:blade_transport_cert_record.id 由应用侧雪花算法发号(当前约 2.1e18), +-- 本脚本自行发号,必须落在应用永远到不了的区间,否则会与真实提交撞主键。 +-- 车辆与司机各占一段,段距 1e17,互不重叠,且都远低于 bigint 上限 9.22e18。 +-- 该号段同时是回退的识别依据(第 4 节)。 +-- ⚠️ 不要改成「取 MAX(id) + 偏移量」:雪花 id 每秒最多 +1000, +-- 取 MAX+1000000 的话应用约 17 分钟就能追平并开始撞号。 +-- ⚠️ 也不要把 id 读出来再拼 UPDATE 的 WHERE:id 约 2.1e18 超过 JS/JSON +-- 安全整数上限 2^53,传回数据库时末位被截断,UPDATE 会静默匹配 0 行—— +-- 记录插进去了、指针却没写,且不报任何错。故全程用 SQL 自关联 +-- (JOIN ... ON r.biz_id = v.id),id 只在库内流转。 +-- +-- 发号用会话变量而不是 ROW_NUMBER():窗口函数要 MySQL 8.0 起,本脚本按 5.7 也能执行写。 +-- +-- 前置条件:blade_transport_cert_record 表与两张业务表上的 +-- certification_status / certification_reject_reason / effective_cert_record_id +-- 列都已存在(见 blade_transport_cert_record_20261002.sql)。缺表时报 +-- 「Table ... doesn't exist」,先补跑那个脚本再执行本脚本。 + +-- ---------------------------- +-- 0. 备份(必须在订正之前执行,仅首次需要) +-- ---------------------------- +-- CREATE TABLE blade_transport_vehicle_certbak AS +-- SELECT id, certification_status, certification_reject_reason, effective_cert_record_id +-- FROM blade_transport_vehicle; +-- CREATE TABLE blade_transport_driver_certbak AS +-- SELECT id, certification_status, certification_reject_reason, effective_cert_record_id +-- FROM blade_transport_driver; +-- CREATE TABLE blade_transport_cert_record_bak AS SELECT * FROM blade_transport_cert_record; + +START TRANSACTION; + +-- ---------------------------- +-- 1. 为尚无生效快照的车辆补「已通过」记录 +-- ---------------------------- +-- 从段内已有的最大号之后接着发,而不是每次从段首发: +-- 订正过一次之后又导入了新车、再跑本脚本时,从段首发号会与已有记录撞主键。 +-- 变量存的是**段内序号**(段基址已减掉),下面 SELECT 里再加回段基址。 +SET @vehicle_seq := (SELECT IFNULL(MAX(id) - 9000000000000000000, 0) + FROM blade_transport_cert_record + WHERE id >= 9000000000000000000 AND id < 9100000000000000000); + +INSERT INTO blade_transport_cert_record + (id, tenant_id, biz_type, biz_id, biz_key, status, snapshot_json, + submit_time, submit_user, create_time, update_time, is_deleted) +SELECT 9000000000000000000 + (@vehicle_seq := @vehicle_seq + 1), + IFNULL(v.tenant_id, '000000'), 'vehicle', v.id, IFNULL(v.plate_no, ''), 1, + JSON_OBJECT( + 'plateNo', v.plate_no, + 'ownerName', v.owner_name, + 'plateColor', v.plate_color, + 'vehicleType', v.vehicle_type, + 'vin', v.vin, + 'outerLength', v.outer_length, + 'outerWidth', v.outer_width, + 'outerHeight', v.outer_height, + 'approvedLoadKg', v.approved_load_kg, + 'tractionMassKg', v.traction_mass_kg, + 'energyType', v.energy_type, + 'businessRelation', v.business_relation, + 'compulsoryScrapDate', v.compulsory_scrap_date, + 'compulsoryScrapLongTerm', IF(v.compulsory_scrap_long_term = 1, 1, 0), + 'drivingLicenseNo', v.driving_license_no, + 'drivingLicenseImage', v.driving_license_image, + 'drivingLicenseMainBack', v.driving_license_main_back, + 'drivingLicenseViceFront', v.driving_license_vice_front, + 'drivingLicenseViceBack', v.driving_license_vice_back, + 'drivingLicenseStartDate', v.driving_license_start_date, + 'drivingLicenseEndDate', v.driving_license_end_date, + 'drivingLicenseLongTerm', IF(v.driving_license_long_term = 1, 1, 0), + 'roadTransportCertNo', v.road_transport_cert_no, + 'roadTransportCertImage', v.road_transport_cert_image, + 'roadTransportCertStartDate', v.road_transport_cert_start_date, + 'roadTransportCertEndDate', v.road_transport_cert_end_date, + 'roadTransportCertLongTerm', IF(v.road_transport_cert_long_term = 1, 1, 0), + 'registrationNo', v.registration_no, + 'registrationImage', v.registration_image + ), + NOW(), + (SELECT dr.user_id + FROM blade_transport_driver dr + WHERE dr.is_deleted = 0 + AND dr.user_id IS NOT NULL + AND dr.driving_vehicle IS NOT NULL + AND FIND_IN_SET(v.plate_no, REPLACE(dr.driving_vehicle, ',', ',')) > 0 + ORDER BY dr.update_time DESC, dr.id DESC + LIMIT 1), + NOW(), NOW(), 0 +FROM blade_transport_vehicle v +LEFT JOIN blade_transport_cert_record r + ON r.biz_type = 'vehicle' AND r.biz_id = v.id + AND r.id >= 9000000000000000000 AND r.id < 9100000000000000000 +WHERE v.is_deleted = 0 + AND v.effective_cert_record_id IS NULL + AND r.id IS NULL +ORDER BY v.id; + +-- 回填指针:靠 biz_id 自关联,不把 id 带出数据库 +UPDATE blade_transport_vehicle v +JOIN blade_transport_cert_record r + ON r.biz_type = 'vehicle' AND r.biz_id = v.id + AND r.id >= 9000000000000000000 AND r.id < 9100000000000000000 AND r.status = 1 +SET v.effective_cert_record_id = r.id, + v.certification_status = 1, + v.certification_reject_reason = NULL +WHERE v.effective_cert_record_id IS NULL AND v.is_deleted = 0; + +-- ---------------------------- +-- 2. 为尚无生效快照的司机补「已通过」记录 +-- ---------------------------- +SET @driver_seq := (SELECT IFNULL(MAX(id) - 9100000000000000000, 0) + FROM blade_transport_cert_record + WHERE id >= 9100000000000000000 AND id < 9200000000000000000); + +INSERT INTO blade_transport_cert_record + (id, tenant_id, biz_type, biz_id, biz_key, status, snapshot_json, + submit_time, submit_user, create_time, update_time, is_deleted) +SELECT 9100000000000000000 + (@driver_seq := @driver_seq + 1), + IFNULL(d.tenant_id, '000000'), 'driver', d.id, IFNULL(d.driver_name, ''), 1, + JSON_OBJECT( + 'realName', d.driver_name, + 'idCardNo', d.id_card_no, + 'idCardFrontUrl', d.id_card_front, + 'idCardBackUrl', d.id_card_back, + 'idCardValidStart', d.id_card_valid_start, + 'idCardValidEnd', d.id_card_valid_end, + 'idCardLongTerm', IF(d.id_card_long_term = 1, 1, 0), + 'driverFileNo', d.driving_license_no, + 'driverClass', d.driving_type, + 'driverLicenseFrontUrl', d.driving_license_front, + 'driverLicenseBackUrl', d.driving_license_back, + 'driverValidStart', d.driving_license_start_date, + 'driverValidEnd', d.driving_license_end_date, + 'driverLongTerm', IF(d.driving_license_long_term = 1, 1, 0), + 'qualificationType', d.qualification_type, + 'qualificationNo', d.qualification_no, + 'qualificationFront', d.qualification_front, + 'qualificationBack', d.qualification_back, + 'qualificationValidEnd', d.qualification_end_date, + 'qualificationLongTerm', IF(d.qualification_long_term = 1, 1, 0), + 'headPhoto', d.head_photo + ), + NOW(), + -- 注册在司机档案上的用户 id 最直接;它为空时退回「按手机号找账号」 + -- (司机登录账号就是手机号)。两者都落空说明这名司机没有系统账号, + -- 此时留 NULL —— 审核列表的司机列本来就取不到人,编不出来。 + IFNULL(d.user_id, + (SELECT u.id + FROM blade_user u + WHERE u.account = d.mobile COLLATE utf8mb4_general_ci + ORDER BY u.id DESC + LIMIT 1)), + NOW(), NOW(), 0 +FROM blade_transport_driver d +LEFT JOIN blade_transport_cert_record r + ON r.biz_type = 'driver' AND r.biz_id = d.id + AND r.id >= 9100000000000000000 AND r.id < 9200000000000000000 +WHERE d.is_deleted = 0 + AND d.effective_cert_record_id IS NULL + AND r.id IS NULL +ORDER BY d.id; + +UPDATE blade_transport_driver d +JOIN blade_transport_cert_record r + ON r.biz_type = 'driver' AND r.biz_id = d.id + AND r.id >= 9100000000000000000 AND r.id < 9200000000000000000 AND r.status = 1 +SET d.effective_cert_record_id = r.id, + d.certification_status = 1, + d.certification_reject_reason = NULL +WHERE d.effective_cert_record_id IS NULL AND d.is_deleted = 0; + +COMMIT; + +-- ---------------------------- +-- 3. 校验(执行后必看,四个都必须返回 0) +-- ---------------------------- +-- 下列查询非 0 说明还有对象没有生效快照,门槛开着它们就不可用。 +-- SELECT COUNT(*) AS vehicle_missing FROM blade_transport_vehicle +-- WHERE effective_cert_record_id IS NULL AND is_deleted = 0; +-- SELECT COUNT(*) AS driver_missing FROM blade_transport_driver +-- WHERE effective_cert_record_id IS NULL AND is_deleted = 0; +-- +-- 下列查询非 0 说明指针指向的不是一条「已通过」记录,生效值取不到内容。 +-- SELECT COUNT(*) AS vehicle_bad_pointer +-- FROM blade_transport_vehicle v +-- LEFT JOIN blade_transport_cert_record r ON r.id = v.effective_cert_record_id +-- WHERE v.effective_cert_record_id IS NOT NULL AND (r.id IS NULL OR r.status <> 1); +-- SELECT COUNT(*) AS driver_bad_pointer +-- FROM blade_transport_driver d +-- LEFT JOIN blade_transport_cert_record r ON r.id = d.effective_cert_record_id +-- WHERE d.effective_cert_record_id IS NOT NULL AND (r.id IS NULL OR r.status <> 1); +-- +-- 快照字段完整性:确认关键字段没有因为键名写错而丢失。 +-- 司机快照必须含 driverClass(准驾车型),车辆快照必须含 drivingLicenseNo(行驶证档案编号)。 +-- SELECT COUNT(*) AS driver_snapshot_bad FROM blade_transport_cert_record +-- WHERE biz_type = 'driver' AND JSON_EXTRACT(snapshot_json, '$.driverClass') IS NULL; +-- SELECT COUNT(*) AS vehicle_snapshot_bad FROM blade_transport_cert_record +-- WHERE biz_type = 'vehicle' AND JSON_EXTRACT(snapshot_json, '$.drivingLicenseNo') IS NULL; +-- +-- 号段账目:号段内的记录数应等于本次订正的对象数(首次订正 = 车辆数 + 尚无快照的在用司机数), +-- 指针落在号段内的对象数也应等于它。两个数对不上说明有对象漏订正。 +-- SELECT COUNT(*) AS in_range_record FROM blade_transport_cert_record +-- WHERE id >= 9000000000000000000 AND id < 9200000000000000000; +-- SELECT COUNT(*) AS driver_backfilled FROM blade_transport_driver +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- SELECT COUNT(*) AS vehicle_backfilled FROM blade_transport_vehicle +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; + +-- ---------------------------- +-- 4. 回退 +-- ---------------------------- +-- 识别依据是 id 号段(>= 9.0e18),不是「备份表里没有的 id」: +-- 号段法不依赖备份表是否建过,也不会误删订正之后产生的真实提交记录。 +-- 回退前先把门槛开关置 false(transport.certification.driver-gate-enabled / +-- vehicle-gate-enabled),否则清掉指针的瞬间所有对象都不可用。 +-- 顺序:先清指针,再删记录;反了会在中间态留下悬空指针。 +-- START TRANSACTION; +-- UPDATE blade_transport_driver +-- SET effective_cert_record_id = NULL, certification_status = NULL, +-- certification_reject_reason = NULL +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- UPDATE blade_transport_vehicle +-- SET effective_cert_record_id = NULL, certification_status = 0, +-- certification_reject_reason = NULL +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- DELETE FROM blade_transport_cert_record +-- WHERE id >= 9000000000000000000 AND id < 9200000000000000000; +-- COMMIT; +-- +-- 车辆 certification_status 还原为 0 是近似值:订正会把原本 0 与 1 的车都写成 1, +-- 原分布无法反推。这只影响页面上的状态标,不影响可用性(重跑订正会再次全部写成 1)。 +-- 要精确还原只能用订正前建好的 blade_transport_vehicle_certbak: +-- UPDATE blade_transport_vehicle v +-- JOIN blade_transport_vehicle_certbak b ON b.id = v.id +-- SET v.certification_status = b.certification_status, +-- v.certification_reject_reason = b.certification_reject_reason; diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql new file mode 100644 index 00000000..8e9b8870 --- /dev/null +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -0,0 +1,140 @@ +-- ---------------------------- +-- 认证记录:建表,司机表 / 车辆表补认证字段与生效快照指针,订正司机表排序规则, +-- 并新增认证审核菜单。可重复执行。 +-- ---------------------------- + +-- ---------------------------- +-- 1. 认证记录表 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `blade_transport_cert_record` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `biz_type` varchar(20) NOT NULL COMMENT '认证对象类型:driver-司机,vehicle-车辆', + `biz_id` bigint(20) NOT NULL COMMENT '认证对象主键:司机ID或车辆ID(车辆按具体车辆,不是司机)', + `biz_key` varchar(100) DEFAULT NULL COMMENT '展示键:司机姓名/车牌号', + `status` int(11) NOT NULL COMMENT '审核状态:0审核中 1已通过 2已驳回 3已作废', + `snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '该次提交的全量认证内容JSON,一经创建不可修改', + `reject_reason` varchar(200) DEFAULT NULL COMMENT '驳回原因,仅状态为已驳回时有值', + `submit_time` datetime DEFAULT NULL COMMENT '提交时间', + `submit_user` bigint(20) DEFAULT NULL COMMENT '提交人', + `audit_time` datetime DEFAULT NULL COMMENT '审核时间', + `audit_user` bigint(20) DEFAULT NULL COMMENT '审核人', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', + `pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_cert_record_pending` (`tenant_id`,`biz_type`,`pending_biz_id`) USING BTREE, + KEY `idx_cert_record_biz` (`biz_type`,`biz_id`,`submit_time`) USING BTREE, + KEY `idx_cert_record_status` (`biz_type`,`status`,`submit_time`) USING BTREE, + KEY `idx_cert_record_submit_time` (`submit_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='认证记录'; + +-- ---------------------------- +-- 2~4. 加列与排序规则订正 +-- ---------------------------- +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_cert_record_20261002`$$ +CREATE PROCEDURE `upgrade_transport_cert_record_20261002`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_status') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交' AFTER `remark`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_reject_reason') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_reject_reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最新一次驳回原因展示缓存' AFTER `certification_status`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'effective_cert_record_id') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_start') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起' AFTER `id_card_back`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_end') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_end` date DEFAULT NULL COMMENT '身份证有效期止' AFTER `id_card_valid_start`; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_long_term') THEN + ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是' AFTER `id_card_valid_end`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' AND column_name = 'effective_cert_record_id') THEN + ALTER TABLE `blade_transport_vehicle` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND table_collation <> 'utf8mb4_general_ci') THEN + ALTER TABLE `blade_transport_driver` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' + AND column_name = 'pending_biz_id' AND generation_expression NOT LIKE '%is_deleted%' + ) THEN + ALTER TABLE `blade_transport_cert_record` + MODIFY COLUMN `pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照'; + END IF; + + -- tenant_id 必须 NOT NULL:唯一索引不对 NULL 去重,允许 NULL 则同一对象可并存两条待审记录 + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' + AND column_name = 'tenant_id' AND is_nullable = 'YES' + ) THEN + UPDATE `blade_transport_cert_record` SET `tenant_id` = '000000' WHERE `tenant_id` IS NULL; + ALTER TABLE `blade_transport_cert_record` + MODIFY COLUMN `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID'; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' + AND column_name = 'certification_status' AND column_comment NOT LIKE '%已作废%' + ) THEN + ALTER TABLE `blade_transport_vehicle` + MODIFY COLUMN `certification_status` int(11) DEFAULT '0' COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废;可用性判定只看 effective_cert_record_id'; + END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' + AND column_name = 'certification_reject_reason' AND column_comment NOT LIKE '%展示缓存%' + ) THEN + ALTER TABLE `blade_transport_vehicle` + MODIFY COLUMN `certification_reject_reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最新一次驳回原因展示缓存'; + END IF; +END$$ + +CALL `upgrade_transport_cert_record_20261002`()$$ +DROP PROCEDURE `upgrade_transport_cert_record_20261002`$$ + +DELIMITER ; + +-- ---------------------------- +-- 5. 菜单 +-- 挂在「运力管理」2079000000000000001 下;ON DUPLICATE KEY UPDATE 使重跑可覆盖旧值。 +-- ---------------------------- +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES +(2079000000000000300, 2079000000000000001, 'cert_certification_audit_driver', '司机审核', 'cert_certification_audit_driver', '/transportCapacity/cert-audit-driver', 'iconfont iconicon_doc', 4, 1, 0, 1, '', '司机认证待审列表', 0), +(2079000000000000301, 2079000000000000300, 'cert_certification_audit_driver_audit', '审核', 'cert_certification_audit_driver_audit', '', '', 1, 2, 0, 1, NULL, '通过/驳回司机认证', 0), +(2079000000000000310, 2079000000000000001, 'cert_certification_audit_vehicle', '车辆审核', 'cert_certification_audit_vehicle', '/transportCapacity/cert-audit-vehicle', 'iconfont iconicon_doc', 5, 1, 0, 1, '', '车辆认证待审列表', 0), +(2079000000000000311, 2079000000000000310, 'cert_certification_audit_vehicle_audit', '审核', 'cert_certification_audit_vehicle_audit', '', '', 1, 2, 0, 1, NULL, '通过/驳回车辆认证', 0) +ON DUPLICATE KEY UPDATE + `parent_id` = VALUES(`parent_id`), + `code` = VALUES(`code`), + `name` = VALUES(`name`), + `alias` = VALUES(`alias`), + `path` = VALUES(`path`), + `source` = VALUES(`source`), + `sort` = VALUES(`sort`), + `category` = VALUES(`category`), + `action` = VALUES(`action`), + `is_open` = VALUES(`is_open`), + `component` = VALUES(`component`), + `remark` = VALUES(`remark`), + `is_deleted` = VALUES(`is_deleted`); diff --git a/doc/sql/transport/blade_transport_driver.sql b/doc/sql/transport/blade_transport_driver.sql index 57ccd4fc..19809bf5 100644 --- a/doc/sql/transport/blade_transport_driver.sql +++ b/doc/sql/transport/blade_transport_driver.sql @@ -13,10 +13,13 @@ CREATE TABLE `blade_transport_driver` ( `education` varchar(20) DEFAULT NULL COMMENT '学历', `address_region` varchar(100) DEFAULT NULL COMMENT '地址区划', `address` varchar(200) DEFAULT NULL COMMENT '详细地址', - `driving_vehicle` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号', + `driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔', `posts` varchar(100) DEFAULT NULL COMMENT '岗位,多个使用逗号分隔', `id_card_front` varchar(1000) DEFAULT NULL COMMENT '身份证正面照', `id_card_back` varchar(1000) DEFAULT NULL COMMENT '身份证反面照', + `id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起', + `id_card_valid_end` date DEFAULT NULL COMMENT '身份证有效期止', + `id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是', `head_photo` varchar(1000) DEFAULT NULL COMMENT '大头照', `driving_type` varchar(20) NOT NULL COMMENT '准驾车型', `driving_license_no` varchar(50) DEFAULT NULL COMMENT '驾驶证档案编号', @@ -39,6 +42,9 @@ CREATE TABLE `blade_transport_driver` ( `emergency_contact_name` varchar(20) NOT NULL COMMENT '紧急联系人姓名', `emergency_contact_mobile` varchar(20) NOT NULL COMMENT '紧急联系人手机号', `remark` varchar(200) DEFAULT NULL COMMENT '备注', + `certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交', + `certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '最新一次驳回原因展示缓存', + `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退', `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', `create_time` datetime DEFAULT NULL COMMENT '创建时间', @@ -54,7 +60,7 @@ CREATE TABLE `blade_transport_driver` ( KEY `idx_transport_driver_license_end` (`driving_license_end_date`) USING BTREE, KEY `idx_transport_driver_qualification_end` (`qualification_end_date`) USING BTREE, KEY `idx_transport_driver_status` (`status`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机管理'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='司机管理'; -- ---------------------------- -- Records of blade_menu for transport capacity driver diff --git a/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql new file mode 100644 index 00000000..b845ecba --- /dev/null +++ b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql @@ -0,0 +1,27 @@ +-- 司机「驾驶车辆」字段扩长:varchar(30) -> varchar(100)。 +-- 该字段是逗号并列的车牌列表,一个司机最多 5 辆,30 字符不够;超长会静默截断成半个车牌。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_driver_driving_vehicle_widen_20261003`$$ +CREATE PROCEDURE `upgrade_transport_driver_driving_vehicle_widen_20261003`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_driver' + AND column_name = 'driving_vehicle' + AND character_maximum_length < 100 + ) THEN + ALTER TABLE `blade_transport_driver` + MODIFY COLUMN `driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔'; + END IF; +END$$ + +CALL `upgrade_transport_driver_driving_vehicle_widen_20261003`()$$ +DROP PROCEDURE `upgrade_transport_driver_driving_vehicle_widen_20261003`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_transport_vehicle.sql b/doc/sql/transport/blade_transport_vehicle.sql index 3a5f3f22..1aa8f30b 100644 --- a/doc/sql/transport/blade_transport_vehicle.sql +++ b/doc/sql/transport/blade_transport_vehicle.sql @@ -8,8 +8,10 @@ CREATE TABLE `blade_transport_vehicle` ( `organization_name` varchar(50) NOT NULL COMMENT '所属组织', `use_department` varchar(50) DEFAULT NULL COMMENT '使用部门', `plate_no` varchar(16) NOT NULL COMMENT '车牌号', + `owner_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所有人(行驶证登记的单位名称,不是司机)', `plate_color` varchar(20) DEFAULT NULL COMMENT '车牌颜色', `vehicle_type` varchar(50) NOT NULL COMMENT '车辆类型', + `vin` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆识别代号(车架号)', `outer_length` int(11) DEFAULT NULL COMMENT '外廓长度,毫米', `outer_width` int(11) DEFAULT NULL COMMENT '外廓宽度,毫米', `outer_height` int(11) DEFAULT NULL COMMENT '外廓高度,毫米', @@ -39,8 +41,9 @@ CREATE TABLE `blade_transport_vehicle` ( `registration_date` date DEFAULT NULL COMMENT '机动车登记日期', `registration_image` varchar(1000) DEFAULT NULL COMMENT '机动车登记本图片', `remark` varchar(200) DEFAULT NULL COMMENT '备注', - `certification_status` int(11) DEFAULT '0' COMMENT '认证状态:0认证中 1认证通过 2认证驳回', - `certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '认证驳回原因', + `certification_status` int(11) DEFAULT '0' COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废;可用性判定只看 effective_cert_record_id', + `certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '最新一次驳回原因展示缓存', + `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退', `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', `create_time` datetime DEFAULT NULL COMMENT '创建时间', diff --git a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql new file mode 100644 index 00000000..5ac828c0 --- /dev/null +++ b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql @@ -0,0 +1,36 @@ +-- 车辆档案补两列:所有人(行驶证登记的单位名称,不是司机)、车辆识别代号(车架号)。 +-- 两列均可空,存量车辆没有值。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_vehicle_owner_vin_20261003`$$ +CREATE PROCEDURE `upgrade_transport_vehicle_owner_vin_20261003`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_vehicle' + AND column_name = 'owner_name' + ) THEN + ALTER TABLE `blade_transport_vehicle` + ADD COLUMN `owner_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所有人(行驶证登记的单位名称,不是司机)' AFTER `plate_no`; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_vehicle' + AND column_name = 'vin' + ) THEN + ALTER TABLE `blade_transport_vehicle` + ADD COLUMN `vin` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆识别代号(车架号)' AFTER `vehicle_type`; + END IF; +END$$ + +CALL `upgrade_transport_vehicle_owner_vin_20261003`()$$ +DROP PROCEDURE `upgrade_transport_vehicle_owner_vin_20261003`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_user_oa_modified_20261010.sql b/doc/sql/transport/blade_user_oa_modified_20261010.sql new file mode 100644 index 00000000..1b92853f --- /dev/null +++ b/doc/sql/transport/blade_user_oa_modified_20261010.sql @@ -0,0 +1,4 @@ +-- 用户表:记录 OA 的 modified(上次更新时间),用于增量同步比对 + +ALTER TABLE `blade_user` + ADD COLUMN `oa_modified` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上次更新时间(modified)' AFTER `is_oa`; diff --git a/doc/sql/transport/blade_waybill_node_punch_20260911.sql b/doc/sql/transport/blade_waybill_node_punch_20260911.sql index f595a23b..6ba2077c 100644 --- a/doc/sql/transport/blade_waybill_node_punch_20260911.sql +++ b/doc/sql/transport/blade_waybill_node_punch_20260911.sql @@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` ( `longitude` decimal(12, 8) DEFAULT NULL COMMENT '经度', `latitude` decimal(12, 8) DEFAULT NULL COMMENT '纬度', `address` varchar(500) DEFAULT NULL COMMENT '打卡地址', - `photos` varchar(2000) DEFAULT NULL COMMENT '凭证照片URL,多张逗号分隔', + `photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张', `weight` varchar(32) DEFAULT NULL COMMENT '重量(吨)', `volume` varchar(32) DEFAULT NULL COMMENT '体积(方)', `quantity` varchar(32) DEFAULT NULL COMMENT '数量(件)', @@ -27,4 +27,4 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` ( PRIMARY KEY (`id`) USING BTREE, KEY `idx_node_punch_waybill` (`waybill_id`, `node_code`, `punch_time`) USING BTREE, KEY `idx_node_punch_driver` (`driver_id`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单过程节点打卡记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运单过程节点打卡记录'; diff --git a/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql new file mode 100644 index 00000000..98a1673f --- /dev/null +++ b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql @@ -0,0 +1,10 @@ +-- ---------------------------- +-- 节点打卡照片列加宽:varchar(2000) 改为 text +-- ---------------------------- +-- 一个节点可传「打卡照片 9 张 + 每个凭证类型各 9 张」,勾了 4 类凭证时共 45 张; +-- 库里实测每张照片含类型的 JSON 串约 111 字符,36 张约 4000 字符,已超 varchar(2000) 一倍。 +-- 超长时严格模式报 Data too long,非严格模式会静默截断成半个 JSON,后者更难查。 +-- 内容形状不变,仍是扁平 [{"type","url"}]。可重复执行。 + +ALTER TABLE `blade_waybill_node_punch` + MODIFY COLUMN `photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张'; diff --git a/doc/sql/transport/transport.sql b/doc/sql/transport/transport.sql index e9ba572e..b2d174cd 100644 --- a/doc/sql/transport/transport.sql +++ b/doc/sql/transport/transport.sql @@ -1420,7 +1420,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint(20) NOT NULL COMMENT '主键', `code` varchar(20) NOT NULL COMMENT '编码', `tmis_code` varchar(5) NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) DEFAULT NULL COMMENT '电报码', `name` varchar(100) NOT NULL COMMENT '车站名称', `railway_line` varchar(100) DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) DEFAULT NULL COMMENT '所属省份编码',