1、新增小程序相关接口

2、调整OA
This commit is contained in:
2026-09-18 16:31:37 +08:00
parent 0fa0eae43c
commit 01993779a7
71 changed files with 6463 additions and 213 deletions
+4
View File
@@ -45,6 +45,10 @@
<groupId>org.springblade</groupId>
<artifactId>blade-user-api</artifactId>
</dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-resource-api</artifactId>
</dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-process-api</artifactId>
@@ -54,7 +54,9 @@ 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.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.IUserService;
import org.springblade.system.wrapper.UserWrapper;
import org.springframework.web.bind.annotation.*;
@@ -77,6 +79,7 @@ import java.util.Map;
public class UserController {
private final IUserService userService;
private final IOASyncService oaSyncService;
/**
* 查询单条
@@ -158,14 +161,16 @@ public class UserController {
}
/**
* 同步IAM账号
* 从OA按页同步人员,并按公司/部门生成组织后绑定到三级部门
*/
@IsAdmin
@PostMapping("/sync-iam-accounts")
@ApiOperationSupport(order = 6)
@Operation(summary = "同步IAM账号")
public R<Integer> syncIamAccounts() {
return R.data(userService.syncIamAccounts());
@Operation(summary = "同步OA人员")
public R<OaPersonSyncPageVO> syncIamAccounts(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "50") Integer size) {
return R.data(oaSyncService.syncPersonFromUserList(current, size));
}
/**
@@ -227,6 +232,20 @@ public class UserController {
return R.status(temp);
}
/**
* 当前用户设置/重置登录密码(小程序首次设密、短信验证后改密)
* <p>
* 对外路径:/blade-system/user/password ;网关别名 /blade-user/password 亦可到达。
*/
@PostMapping("/password")
@ApiOperationSupport(order = 10)
@Operation(summary = "设置登录密码", description = "当前登录用户设置密码,无需原密码")
public R password(BladeUser user,
@Parameter(description = "新密码", required = true) @RequestParam String password,
@Parameter(description = "确认密码", required = true) @RequestParam String password2) {
return R.status(userService.setPassword(user.getUserId(), password, password2));
}
/**
* 管理员修改密码
*/
@@ -0,0 +1,93 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.controller;
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.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.system.pojo.dto.PhoneChangeDTO;
import org.springblade.system.pojo.dto.PhoneVerifyDTO;
import org.springblade.system.service.IUserPhoneService;
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;
/**
* 用户手机号变更(小程序「修改手机号」)
* <p>
* 对外路径:/blade-system/user/phone/** ;网关别名 /blade-user/phone/** 亦可到达。
*
* @author Chill
*/
@NonDS
@RestController
@AllArgsConstructor
@RequestMapping("/user/phone")
@Tag(name = "用户手机号", description = "修改手机号")
public class UserPhoneController {
private final IUserPhoneService userPhoneService;
/**
* 发送短信验证码(需登录)
* <p>
* 当前手机号、未占用的新手机号均可发送;新号若已被其他账号占用则拒绝。
*/
@PostMapping("/send-code")
@ApiOperationSupport(order = 1)
@Operation(summary = "发送手机号变更验证码", description = "传入明文手机号,返回短信校验 id")
public R sendCode(@Parameter(description = "手机号", required = true) @RequestParam String phone) {
return userPhoneService.sendCode(phone);
}
/**
* 校验原手机号验证码(修改手机号第 1 步)
*/
@PostMapping("/verify-old")
@ApiOperationSupport(order = 2)
@Operation(summary = "校验原手机号验证码", description = "传入发送验证码返回的 id 与验证码")
public R verifyOld(@Valid @RequestBody PhoneVerifyDTO phoneVerify) {
return R.status(userPhoneService.verifyOldPhone(phoneVerify));
}
/**
* 绑定新手机号(修改手机号第 3 步,需先完成 verify-old
*/
@PostMapping("/change")
@ApiOperationSupport(order = 3)
@Operation(summary = "更换手机号", description = "传入新手机号及短信校验 id、验证码")
public R change(@Valid @RequestBody PhoneChangeDTO phoneChange) {
return R.status(userPhoneService.changePhone(phoneChange));
}
}
@@ -64,11 +64,11 @@ public interface UserConvert {
@Mapping(target = "password", ignore = true)
@Mapping(target = "birthday", ignore = true)
@Mapping(target = "sex", ignore = true)
@Mapping(target = "account", ignore = true)
@Mapping(source = "workcode", target = "code")
@Mapping(source = "lastname", target = "name")
@Mapping(source = "lastname", target = "realName")
@Mapping(source = "mobile", target = "phone")
@Mapping(source = "mobile", target = "account")
@Mapping(source = "email", target = "email")
User baseConvert(OAPersonResponse person);
@@ -86,7 +86,7 @@ public interface UserConvert {
*/
default User person2user(OAPersonResponse person, String defaultPassword) {
User user = baseConvert(person);
user.setAccount(resolveAccount(person));
// 密码
user.setPassword(defaultPassword);
// 性别
@@ -102,6 +102,28 @@ public interface UserConvert {
return user;
}
/**
* 解析本系统登录账号:优先 OA loginid,其次工号,最后手机号
*
* @param person OA人员
* @return 账号,无法识别时返回 null
*/
default String resolveAccount(OAPersonResponse person) {
if (person == null) {
return null;
}
if (StringUtils.isNotBlank(person.getLoginid())) {
return person.getLoginid().trim();
}
if (StringUtils.isNotBlank(person.getWorkcode())) {
return person.getWorkcode().trim();
}
if (StringUtils.isNotBlank(person.getMobile())) {
return person.getMobile().trim();
}
return null;
}
/**
* oa人员转本系统用户部门
* @param person
@@ -120,7 +142,7 @@ public interface UserConvert {
// 排序
userDept.setSort(OAUtils.parseInt(person.getDsporder()));
// 用户id
userDept.setUserId(userMap.get(person.getMobile()));
userDept.setUserId(userMap.get(resolveAccount(person)));
userDept.setSyncTime(new Date());
return userDept;
}
@@ -231,4 +231,10 @@ public class SysClient implements ISysClient {
.orderByAsc(CargoType::getCargoCode)));
}
@Override
@GetMapping(PERMISSIONS)
public R<List<String>> getPermissions(String roleId) {
return R.data(menuService.permissionCodes(roleId));
}
}
@@ -133,4 +133,10 @@ public class UserClient implements IUserClient {
return R.data(service.remove(Wrappers.<User>query().lambda().in(User::getTenantId, Func.toStrList(tenantIds))));
}
@Override
@PostMapping(BIND_WX_MINI_OPENID)
public R<Boolean> bindWxMiniOpenId(String tenantId, Long userId, String openid, String phone) {
return R.data(service.bindWxMiniOpenId(tenantId, userId, openid, phone));
}
}
@@ -77,6 +77,14 @@ public interface IMenuService extends IService<Menu> {
*/
List<MenuVO> buttons(String roleId);
/**
* 权限标识集合(按钮编号,与前端 GetButtons 叶子 code 一致)
*
* @param roleId 角色id
* @return 权限标识
*/
List<String> permissionCodes(String roleId);
/**
* 树形结构
*
@@ -1,5 +1,7 @@
package org.springblade.system.service;
import org.springblade.system.pojo.vo.OaPersonSyncPageVO;
/**
* oa同步接口
* @author bfhuange
@@ -18,4 +20,20 @@ public interface IOASyncService {
* @param syncAll 是否同步所有
*/
void syncPersonAndPushMK(boolean syncAll);
/**
* 从 OA 人员接口全量同步组织与人员,不推送 MK
*
* @return 处理的人员数量
*/
int syncPersonFromUserList();
/**
* 按页从 OA 人员接口同步组织与人员
*
* @param current 当前页,从 1 开始
* @param size 每页条数
* @return 本页同步结果
*/
OaPersonSyncPageVO syncPersonFromUserList(int current, int size);
}
@@ -0,0 +1,63 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.service;
import org.springblade.core.tool.api.R;
import org.springblade.system.pojo.dto.PhoneChangeDTO;
import org.springblade.system.pojo.dto.PhoneVerifyDTO;
/**
* 用户手机号变更服务
*
* @author Chill
*/
public interface IUserPhoneService {
/**
* 发送变更手机号短信验证码
*
* @param phone 明文手机号
* @return 含短信校验 id 的响应
*/
R sendCode(String phone);
/**
* 校验原手机号验证码,通过后写入短期凭证
*
* @param phoneVerify 校验参数
* @return 是否通过
*/
boolean verifyOldPhone(PhoneVerifyDTO phoneVerify);
/**
* 校验新手机号验证码并更换手机号
*
* @param phoneChange 更换参数
* @return 是否成功
*/
boolean changePhone(PhoneChangeDTO phoneChange);
}
@@ -188,6 +188,11 @@ public interface IUserService extends BaseService<User> {
*/
UserInfo userInfo(UserOauth userOauth);
/**
* 绑定微信小程序 openid 到已有用户(blade_user_oauthsource=WECHAT_MINI
*/
boolean bindWxMiniOpenId(String tenantId, Long userId, String openid, String phone);
/**
* 根据租户与账号获取用户
*
@@ -164,6 +164,35 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements IM
return menuWrapper.listNodeVO(buttons);
}
@Override
public List<String> permissionCodes(String roleId) {
List<String> permissionCodes = new ArrayList<>();
// Feign 调用时无登录态,不能走 AuthUtil.isAdministrator() 分支;按 roleId 取按钮权限
List<Menu> buttons = StringUtil.isBlank(roleId)
? Collections.emptyList()
: baseMapper.buttons(Func.toLongList(roleId));
MenuWrapper menuWrapper = new MenuWrapper();
collectLeafPermissionCodes(menuWrapper.listNodeVO(buttons), permissionCodes);
return permissionCodes;
}
/**
* 递归收集按钮树叶子节点的权限编号(与前端 SET_PERMISSION 逻辑一致)
*/
private void collectLeafPermissionCodes(List<MenuVO> menuList, List<String> permissionCodes) {
if (menuList == null || menuList.isEmpty()) {
return;
}
for (MenuVO menu : menuList) {
List<MenuVO> children = menu.getChildren();
if (children != null && !children.isEmpty()) {
collectLeafPermissionCodes(children, permissionCodes);
} else if (StringUtil.isNotBlank(menu.getCode())) {
permissionCodes.add(menu.getCode());
}
}
}
@Override
public List<TreeNode> tree() {
return ForestNodeMerger.merge(baseMapper.tree());
@@ -11,16 +11,14 @@ import org.springblade.common.constant.DictTypeEnum;
import org.springblade.core.cache.utils.CacheUtil;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.DigestUtil;
import org.springblade.system.cache.DictCache;
import org.springblade.system.cache.ParamCache;
import org.springblade.system.convert.DeptConvert;
import org.springblade.system.convert.UserConvert;
import org.springblade.system.log.ComposeLogUtil;
import org.springblade.system.pojo.entity.*;
import org.springblade.system.pojo.enums.DataSync;
import org.springblade.system.pojo.enums.DeptCategory;
import org.springblade.system.pojo.vo.UserDeptIdsVO;
import org.springblade.system.pojo.vo.OaPersonSyncPageVO;
import org.springblade.system.service.*;
import org.springblade.system.util.DataSyncRecordUtils;
import org.springblade.thirdparty.oa.constant.OAConstant;
@@ -29,6 +27,8 @@ import org.springblade.thirdparty.oa.feign.IOAClient;
import org.springblade.thirdparty.oa.pojo.response.OACompanyResponse;
import org.springblade.thirdparty.oa.pojo.response.OADepartmentResponse;
import org.springblade.thirdparty.oa.pojo.response.OAPersonResponse;
import org.springblade.thirdparty.oa.pojo.response.OAResponse;
import org.springblade.thirdparty.oa.pojo.response.OAResponseData;
import org.springblade.thirdparty.oa.pojo.search.OACompanySearch;
import org.springblade.thirdparty.oa.pojo.search.OADepartmentSearch;
import org.springblade.thirdparty.oa.pojo.search.OAPersonSearch;
@@ -38,6 +38,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@@ -54,20 +55,14 @@ import static org.springblade.core.cache.constant.CacheConstant.USER_CACHE;
@RequiredArgsConstructor
@Service
public class OASyncServiceImpl implements IOASyncService {
/**
* 用户表部门id最大长度
*/
private static final int MAX_DEPT_ID_LENGTH = 2000;
private final IOAClient oaClient;
private final DeptConvert deptConvert;
private final IDeptService deptService;
private final IUserService userService;
private final UserConvert userConvert;
private final IUserDeptService userDeptService;
private final IRoleService roleService;
private final IMKPushService mkPushService;
private final IDataSyncRecordService dataSyncRecordService;
private final OaUserListSyncHelper oaUserListSyncHelper;
@Transactional(rollbackFor = Exception.class)
@Override
@@ -104,6 +99,31 @@ public class OASyncServiceImpl implements IOASyncService {
}
}
@Transactional(rollbackFor = Exception.class)
@Override
public int syncPersonFromUserList() {
AtomicInteger syncedCount = new AtomicInteger();
try {
ComposeLogUtil.addLog(log);
this.syncAndRecord(DataSyncRecordUtils::createOAPersonFetch, startTime ->
syncedCount.set(this.syncPersonFromOa(null)), true);
return syncedCount.get();
} finally {
ComposeLogUtil.removeLastLog();
}
}
@Transactional(rollbackFor = Exception.class)
@Override
public OaPersonSyncPageVO syncPersonFromUserList(int current, int size) {
try {
ComposeLogUtil.addLog(log);
return this.syncPersonFromOaPage(current, size);
} finally {
ComposeLogUtil.removeLastLog();
}
}
/**
* 同步并记录
*
@@ -213,174 +233,93 @@ public class OASyncServiceImpl implements IOASyncService {
* @param startTime 查询开始时间
*/
private void syncPerson(Date startTime) {
String subCompanyIds = getSubCompanyIds();
if (StringUtils.isEmpty(subCompanyIds)) {
ComposeLogUtil.getLastLog().warn("未查询到子公司查询参数");
return;
}
// 1. 设置查询参数
OAPersonSearch personSearch = new OAPersonSearch();
personSearch.setCurPage(1);
personSearch.setSubcompanyid1(subCompanyIds);
if (startTime != null) {
// 开始时间不为空,设置修改时间参数
personSearch.setModified(DateUtil.format(startTime, DateUtil.PATTERN_DATETIME));
}
// 2. 分页查询并处理数据
OAUtils.pageSyncHandler(personSearch, param -> oaClient.queryPersonPage(new OASearch<>(param)), response -> {
ComposeLogUtil.getLastLog().error("调用OA接口查询人员信息失败 {}", JSON.toJSONString(response));
return new ServiceException("调用OA接口查询人员信息失败");
}, 10000, ComposeLogUtil.getLastLog()::info).accept(this::handlePerson);
// 清除用户缓存
CacheUtil.clear(USER_CACHE);
this.syncPersonFromOa(startTime);
}
/**
* 处理oa人员
* @param oaPersons
* 从 OA 人员列表同步组织与人员
*
* @param startTime 增量查询开始时间,为空则全量
* @return 处理的人员数量
*/
private void handlePerson(List<OAPersonResponse> oaPersons) {
if (CollectionUtil.isEmpty(oaPersons)) {
// 数据为空,直接返回
return;
}
// 根公司id
String rootCompanyId = getRootCompanyId();
if (rootCompanyId == null) {
return;
}
// 根公司下要同步的部门id
Set<String> rootCompanyDeptIds = getRootCompanyDeptIds(rootCompanyId);
oaPersons = oaPersons.stream()
// 公司不是根公司,或者部门在根公司下要同步的部门列表中
.filter(oaPerson -> !rootCompanyId.equals(oaPerson.getSubcompanyid1()) || rootCompanyDeptIds.contains(oaPerson.getDepartmentid()))
.toList();
// 根据手机号转成set
TreeSet<OAPersonResponse> oaPersonSet = CollectionUtil.toTreeSet(oaPersons, Comparator.comparing(OAPersonResponse::getMobile));
// 默认密码
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
// 转换数据
List<User> users = oaPersonSet.stream()
// 只需要手机不为空的
.filter(person -> StringUtils.isNotBlank(person.getMobile()))
.map(person -> userConvert.person2user(person, defaultPassword))
.toList();
List<String> phones = users.stream()
.map(User::getPhone)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
// 查询所有用户
Map<String, Long> userMap = userService.list(Wrappers.<User>lambdaQuery()
//.eq(User::getIsDeleted, BladeConstant.DB_NOT_DELETED)
.in(User::getPhone, phones)
).stream()
// 解密手机号
.peek(userService::decryptPhone)
.collect(Collectors.toMap(User::getPhone, User::getId, (a, b) -> b));
// 数据库存在的所有用户id
Set<Long> existsUserIds = new HashSet<>(userMap.values());
users.forEach(user -> {
if (userMap.containsKey(user.getPhone())) {
// 根据手机号获取对应的用户id
user.setId(userMap.get(user.getPhone()));
// 清空密码,不修改密码
user.setPassword(null);
} else {
// 没有就生成一个id
user.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
userService.encryptPhone(user);
userMap.put(user.getPhone(), user.getId());
}
});
private int syncPersonFromOa(Date startTime) {
OAPersonSearch personSearch = buildPersonSearch(startTime);
List<OAPersonResponse> oaPersons = new ArrayList<>();
OAUtils.pageSyncHandler(personSearch, param -> oaClient.queryPersonPage(new OASearch<>(param)), response -> {
ComposeLogUtil.getLastLog().error("调用OA接口查询人员信息失败 {}", JSON.toJSONString(response));
return new ServiceException("调用OA接口查询人员信息失败");
}, 10000, ComposeLogUtil.getLastLog()::info).accept(oaPersons::addAll);
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
CacheUtil.clear(USER_CACHE);
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
return personSyncCount.getSyncedCount();
}
// 不存在的新增
List<User> addUsers = users.stream()
.filter(user -> !existsUserIds.contains(user.getId()))
.toList();
if (CollectionUtil.isNotEmpty(addUsers)) {
ComposeLogUtil.getLastLog().info("批量新增用户:{}", addUsers.size());
userService.saveBatch(addUsers);
/**
* 按页从 OA 人员列表同步组织与人员
*
* @param current 当前页
* @param size 每页条数
* @return 本页同步结果
*/
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size) {
int pageNo = current < 1 ? 1 : current;
int pageSize = size < 1 ? 50 : Math.min(size, 200);
OAPersonSearch personSearch = buildPersonSearch(null);
personSearch.setCurPage(pageNo);
personSearch.setPageSize(pageSize);
OAResponse<OAPersonResponse> oaResponse = oaClient.queryPersonPage(new OASearch<>(personSearch));
if (oaResponse == null || !OAConstant.OA_SUCCESS_CODE.equals(oaResponse.getCode()) || oaResponse.getData() == null) {
ComposeLogUtil.getLastLog().error("调用OA接口查询人员信息失败 {}", JSON.toJSONString(oaResponse));
throw new ServiceException("调用OA接口查询人员信息失败");
}
// 存在的修改
List<User> updateUsers = users.stream()
.filter(user -> existsUserIds.contains(user.getId()))
.toList();
if (CollectionUtil.isNotEmpty(updateUsers)) {
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
userService.updateBatchById(updateUsers);
}
// 没有手机号的数据 = 手机号为空的数量
long noPhoneNum = oaPersons.stream()
.map(OAPersonResponse::getMobile)
.filter(StringUtils::isBlank)
.count();
ComposeLogUtil.getLastLog().info("没有手机号的数据:{}", noPhoneNum);
OAResponseData<OAPersonResponse> responseData = oaResponse.getData();
List<OAPersonResponse> oaPersons = responseData.getDataList() == null
? Collections.emptyList() : responseData.getDataList();
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
CacheUtil.clear(USER_CACHE);
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
OaPersonSyncPageVO pageVO = new OaPersonSyncPageVO();
pageVO.setCurrent(pageNo);
pageVO.setSize(pageSize);
pageVO.setTotal(totalSize);
pageVO.setFetchedCount(oaPersons.size());
pageVO.setSyncedCount(personSyncCount.getSyncedCount());
pageVO.setSkippedCount(personSyncCount.getSkippedCount());
boolean finished = oaPersons.isEmpty()
|| oaPersons.size() < pageSize
|| (long) pageNo * pageSize >= totalSize;
pageVO.setFinished(finished);
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 {}/{},成功{},跳过{}",
pageNo, totalSize, personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
return pageVO;
}
Map<String, Long> userDeptMap = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
.in(UserDept::getUserId, userMap.values())
).stream()
.collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (a, b) -> b));
// 数据库存在的所有用户部门id
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
List<UserDept> userDeptList = oaPersons.stream()
// 只要包含用户手机号的
.filter(oaPerson -> userMap.containsKey(oaPerson.getMobile()))
.map(oaPerson -> userConvert.person2userDept(oaPerson, userMap))
.toList();
userDeptList.forEach(userDept -> {
String userDeptKey = getUserDeptKey(userDept);
if (userDeptMap.containsKey(userDeptKey)) {
// 根据key获取用户部门id
userDept.setId(userDeptMap.get(userDeptKey));
} else {
// 没有就生成一个id
userDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
}
});
// 不存在的新增
List<UserDept> addList = userDeptList.stream()
.filter(userDept -> !existsUserDeptIds.contains(userDept.getId()))
.toList();
if (CollectionUtil.isNotEmpty(addList)) {
ComposeLogUtil.getLastLog().info("批量新增用户部门:{}", addList.size());
userDeptService.saveBatch(addList);
}
// 存在的修改
List<UserDept> updateList = userDeptList.stream()
.filter(userDept -> existsUserDeptIds.contains(userDept.getId()))
.toList();
if (CollectionUtil.isNotEmpty(updateList)) {
ComposeLogUtil.getLastLog().info("批量修改用户部门:{}", updateList.size());
userDeptService.updateBatchById(updateList);
}
// 回写部门id到用户表
Collection<Long> userIds = userMap.values();
List<UserDeptIdsVO> list = userDeptService.queryUserDeptIds(userIds);
// 查询没有角色的用户id
Set<Long> noRoleUserIds = userService.list(Wrappers.<User>lambdaQuery()
.in(User::getId, userIds)
.isNull(User::getRoleId)
).stream()
.map(User::getId)
.collect(Collectors.toSet());
// 获取默认角色id
String defaultRoleId = getDefaultRoleId();
List<User> updateUserParams = list.stream()
// 过滤掉空部门id及长度超长的
.filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()) && userDeptIds.getDeptIds().length() <= MAX_DEPT_ID_LENGTH)
.map(userDeptIds -> {
User user = new User();
user.setId(userDeptIds.getUserId());
user.setDeptId(userDeptIds.getDeptIds());
user.setDeptCodes(userDeptIds.getDeptCodes());
if (noRoleUserIds.contains(userDeptIds.getUserId())) {
user.setRoleId(defaultRoleId);
}
return user;
}).toList();
userService.updateBatchById(updateUserParams);
/**
* 组装 OA 人员分页查询参数
*
* @param startTime 增量查询开始时间
* @return 查询参数
*/
private OAPersonSearch buildPersonSearch(Date startTime) {
OAPersonSearch personSearch = new OAPersonSearch();
personSearch.setCurPage(1);
personSearch.setPageSize(200);
personSearch.setCreated("");
personSearch.setWorkcode("");
personSearch.setSubcompanyid1("");
personSearch.setDepartmentid("");
personSearch.setJobtitleid("");
personSearch.setId("");
personSearch.setLoginid("");
personSearch.setIsadaccount("");
personSearch.setModified(startTime == null ? "" : DateUtil.format(startTime, DateUtil.PATTERN_DATETIME));
return personSearch;
}
/**
@@ -634,16 +573,4 @@ public class OASyncServiceImpl implements IOASyncService {
// 部门编码去掉前缀,就是oa的id
return dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, "");
}
/**
* 获取用户部门唯一标识,用户id+公司编码+部门编码
* @param userDept
* @return
*/
private String getUserDeptKey(UserDept userDept) {
if (userDept == null) {
return null;
}
return userDept.getUserId() + userDept.getCompanyCode() + userDept.getDeptCode();
}
}
@@ -0,0 +1,609 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springblade.common.constant.DataStatusEnum;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.utils.DigestUtil;
import org.springblade.system.cache.ParamCache;
import org.springblade.system.convert.UserConvert;
import org.springblade.system.log.ComposeLogUtil;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.Role;
import org.springblade.system.pojo.entity.User;
import org.springblade.system.pojo.entity.UserDept;
import org.springblade.system.pojo.enums.DeptCategory;
import org.springblade.system.pojo.vo.UserDeptIdsVO;
import org.springblade.system.service.IDeptService;
import org.springblade.system.service.IRoleService;
import org.springblade.system.service.IUserDeptService;
import org.springblade.system.service.IUserService;
import org.springblade.thirdparty.oa.constant.OAConvertConstant;
import org.springblade.thirdparty.oa.pojo.response.OAPersonResponse;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD;
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_ROLE;
import static org.springblade.common.constant.CommonConstant.DEFAULT_ROLE;
import static org.springblade.common.constant.CommonConstant.YES;
/**
* 从 OA 人员列表提取组织并同步人员
*
* @author Chill
*/
@Component
@RequiredArgsConstructor
public class OaUserListSyncHelper {
private static final int MAX_DEPT_ID_LENGTH = 2000;
private final IDeptService deptService;
private final IUserService userService;
private final UserConvert userConvert;
private final IUserDeptService userDeptService;
private final IRoleService roleService;
/**
* 从人员数据提取二级公司、三级部门,并挂到「桂物物流集团」下
*
* @param oaPersons OA人员
* @return 组织索引
*/
public OaOrgIndex syncOrgsFromPersons(List<OAPersonResponse> oaPersons) {
OaOrgIndex orgIndex = new OaOrgIndex();
if (CollectionUtil.isEmpty(oaPersons)) {
return orgIndex;
}
Date orgSyncStart = new Date();
Dept rootCompany = this.getOrCreateRootCompany();
String tenantId = resolveTenantId();
Map<String, OAPersonResponse> companyPersonMap = new LinkedHashMap<>();
Map<String, OAPersonResponse> departmentPersonMap = new LinkedHashMap<>();
for (OAPersonResponse oaPerson : oaPersons) {
String companyKey = resolveCompanyKey(oaPerson);
if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
companyPersonMap.putIfAbsent(companyKey, oaPerson);
}
String departmentKey = resolveDepartmentKey(oaPerson);
if (StringUtils.isNotBlank(departmentKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())
&& StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
departmentPersonMap.putIfAbsent(departmentKey, oaPerson);
}
}
Map<String, Dept> existingCompanyByCode = new HashMap<>();
Map<String, Dept> existingCompanyByName = new HashMap<>();
deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getParentId, rootCompany.getId())
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
).forEach(dept -> {
if (StringUtils.isNotBlank(dept.getDeptCode())) {
existingCompanyByCode.put(dept.getDeptCode(), dept);
}
if (StringUtils.isNotBlank(dept.getDeptName())) {
existingCompanyByName.put(dept.getDeptName(), dept);
}
});
List<Dept> addCompanies = new ArrayList<>();
List<Dept> updateCompanies = new ArrayList<>();
companyPersonMap.forEach((companyKey, oaPerson) -> {
String oaCode = buildCompanyCode(oaPerson, companyKey);
Dept existing = existingCompanyByCode.get(oaCode);
if (existing == null) {
existing = existingCompanyByName.get(oaPerson.getSubcompanyname());
}
Dept company = this.upsertOrg(oaPerson.getSubcompanyname(), oaCode, existing, rootCompany, tenantId,
DeptCategory.COMPANY, addCompanies, updateCompanies);
orgIndex.companyByOaId.put(companyKey, company);
});
this.saveOrgs(addCompanies, updateCompanies, DeptCategory.COMPANY);
Map<String, Dept> existingDeptByCode = new HashMap<>();
Map<String, Dept> existingDeptByParentAndName = new HashMap<>();
List<Long> companyIds = orgIndex.companyByOaId.values().stream()
.map(Dept::getId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (CollectionUtil.isNotEmpty(companyIds)) {
deptService.list(Wrappers.<Dept>lambdaQuery()
.in(Dept::getParentId, companyIds)
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
).forEach(dept -> {
if (StringUtils.isNotBlank(dept.getDeptCode())) {
existingDeptByCode.put(dept.getDeptCode(), dept);
}
if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getDeptName())) {
existingDeptByParentAndName.put(dept.getParentId() + "#" + dept.getDeptName(), dept);
}
});
}
List<Dept> addDepartments = new ArrayList<>();
List<Dept> updateDepartments = new ArrayList<>();
departmentPersonMap.forEach((departmentKey, oaPerson) -> {
Dept parentCompany = orgIndex.findCompany(oaPerson);
if (parentCompany == null || parentCompany.getId() == null) {
return;
}
String oaCode = buildDepartmentCode(oaPerson, departmentKey);
Dept existing = existingDeptByCode.get(oaCode);
if (existing == null) {
existing = existingDeptByParentAndName.get(parentCompany.getId() + "#" + oaPerson.getDepartmentname());
}
Dept department = this.upsertOrg(oaPerson.getDepartmentname(), oaCode, existing, parentCompany, tenantId,
DeptCategory.DEPT, addDepartments, updateDepartments);
orgIndex.deptByOaId.put(departmentKey, department);
orgIndex.deptByCompanyAndName.put(resolveCompanyKey(oaPerson) + "#" + oaPerson.getDepartmentname(), department);
});
this.saveOrgs(addDepartments, updateDepartments, DeptCategory.DEPT);
deptService.updateAncestors(orgSyncStart);
ComposeLogUtil.getLastLog().info("同步人员提取组织完成,二级公司{}个,三级部门{}个",
orgIndex.companyByOaId.size(), orgIndex.deptByOaId.size());
return orgIndex;
}
/**
* 同步人员并绑定到三级部门
*
* @param oaPersons OA人员
* @param orgIndex 组织索引
* @return 本批同步成功与跳过数量
*/
public PersonSyncCount handlePerson(List<OAPersonResponse> oaPersons, OaOrgIndex orgIndex) {
if (CollectionUtil.isEmpty(oaPersons)) {
return new PersonSyncCount(0, 0);
}
Map<String, OAPersonResponse> uniquePersonMap = new LinkedHashMap<>();
int skippedCount = 0;
for (OAPersonResponse oaPerson : oaPersons) {
String account = userConvert.resolveAccount(oaPerson);
if (StringUtils.isBlank(account)) {
skippedCount++;
continue;
}
uniquePersonMap.putIfAbsent(account, oaPerson);
}
if (uniquePersonMap.isEmpty()) {
ComposeLogUtil.getLastLog().warn("OA人员均缺少loginid/工号/手机号,跳过人员同步");
return new PersonSyncCount(0, skippedCount);
}
String tenantId = resolveTenantId();
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
List<User> users = uniquePersonMap.values().stream()
.map(person -> {
User user = userConvert.person2user(person, defaultPassword);
user.setTenantId(tenantId);
return user;
})
.toList();
List<String> accounts = users.stream()
.map(User::getAccount)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
List<String> phones = users.stream()
.map(User::getPhone)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
Map<String, User> existingByAccount = new HashMap<>();
Map<String, User> existingByPhone = new HashMap<>();
userService.list(Wrappers.<User>lambdaQuery()
.and(wrapper -> {
wrapper.in(User::getAccount, accounts);
if (CollectionUtil.isNotEmpty(phones)) {
wrapper.or().in(User::getPhone, phones);
}
})
).stream()
.peek(userService::decryptPhone)
.forEach(user -> {
if (StringUtils.isNotBlank(user.getAccount())) {
existingByAccount.put(user.getAccount(), user);
}
if (StringUtils.isNotBlank(user.getPhone())) {
existingByPhone.put(user.getPhone(), user);
}
});
Map<String, Long> userMap = new HashMap<>();
Set<Long> existsUserIds = new HashSet<>();
users.forEach(user -> {
User existingUser = existingByAccount.get(user.getAccount());
if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) {
existingUser = existingByPhone.get(user.getPhone());
}
if (existingUser != null) {
user.setId(existingUser.getId());
user.setPassword(null);
existsUserIds.add(existingUser.getId());
} else {
user.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
user.setPostId("-1");
user.setPersonCategory(1);
user.setDataScopeRange(1);
user.setDataLevelRange(1);
user.setIncludeNewCustomer(0);
userService.encryptPhone(user);
}
userMap.put(user.getAccount(), user.getId());
});
List<User> addUsers = users.stream()
.filter(user -> !existsUserIds.contains(user.getId()))
.toList();
if (CollectionUtil.isNotEmpty(addUsers)) {
ComposeLogUtil.getLastLog().info("批量新增用户:{}", addUsers.size());
userService.saveBatch(addUsers);
}
List<User> updateUsers = users.stream()
.filter(user -> existsUserIds.contains(user.getId()))
.toList();
if (CollectionUtil.isNotEmpty(updateUsers)) {
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
userService.updateBatchById(updateUsers);
}
ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount);
if (userMap.isEmpty()) {
return new PersonSyncCount(0, skippedCount);
}
Map<String, Long> userDeptMap = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
.in(UserDept::getUserId, userMap.values())
).stream()
.collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second));
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
List<UserDept> userDeptList = oaPersons.stream()
.filter(oaPerson -> userMap.containsKey(userConvert.resolveAccount(oaPerson)))
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
.filter(Objects::nonNull)
.toList();
userDeptList.forEach(userDept -> {
String userDeptKey = getUserDeptKey(userDept);
if (userDeptMap.containsKey(userDeptKey)) {
userDept.setId(userDeptMap.get(userDeptKey));
} else {
userDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
}
});
List<UserDept> addList = userDeptList.stream()
.filter(userDept -> !existsUserDeptIds.contains(userDept.getId()))
.toList();
if (CollectionUtil.isNotEmpty(addList)) {
ComposeLogUtil.getLastLog().info("批量新增用户部门:{}", addList.size());
userDeptService.saveBatch(addList);
}
List<UserDept> updateList = userDeptList.stream()
.filter(userDept -> existsUserDeptIds.contains(userDept.getId()))
.toList();
if (CollectionUtil.isNotEmpty(updateList)) {
ComposeLogUtil.getLastLog().info("批量修改用户部门:{}", updateList.size());
userDeptService.updateBatchById(updateList);
}
Collection<Long> userIds = userMap.values();
List<UserDeptIdsVO> list = userDeptService.queryUserDeptIds(userIds);
Set<Long> noRoleUserIds = userService.list(Wrappers.<User>lambdaQuery()
.in(User::getId, userIds)
.and(wrapper -> wrapper.isNull(User::getRoleId)
.or().eq(User::getRoleId, "")
.or().eq(User::getRoleId, "-1"))
).stream()
.map(User::getId)
.collect(Collectors.toSet());
String defaultRoleId = getDefaultRoleId();
List<User> updateUserParams = list.stream()
.filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()) && userDeptIds.getDeptIds().length() <= MAX_DEPT_ID_LENGTH)
.map(userDeptIds -> {
User user = new User();
user.setId(userDeptIds.getUserId());
user.setDeptId(userDeptIds.getDeptIds());
user.setDeptCodes(userDeptIds.getDeptCodes());
if (noRoleUserIds.contains(userDeptIds.getUserId())) {
user.setRoleId(defaultRoleId);
}
return user;
}).toList();
if (CollectionUtil.isNotEmpty(updateUserParams)) {
userService.updateBatchById(updateUserParams);
}
return new PersonSyncCount(users.size(), skippedCount);
}
private Dept getOrCreateRootCompany() {
Dept rootCompany = deptService.getOne(Wrappers.<Dept>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;
}
private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId,
DeptCategory deptCategory, List<Dept> addList, List<Dept> updateList) {
if (existing != null) {
Dept updateParam = new Dept();
updateParam.setId(existing.getId());
updateParam.setDeptName(name);
updateParam.setFullName(name);
updateParam.setShortName(name);
updateParam.setParentId(parent.getId());
updateParam.setParentCode(parent.getDeptCode());
updateParam.setAncestors(buildAncestors(parent));
updateParam.setIsOa(YES);
updateParam.setSyncTime(new Date());
updateList.add(updateParam);
existing.setDeptName(name);
existing.setFullName(name);
existing.setShortName(name);
existing.setParentId(parent.getId());
existing.setParentCode(parent.getDeptCode());
existing.setAncestors(updateParam.getAncestors());
return existing;
}
Dept dept = this.buildOrgDept(name, oaCode, parent, tenantId, deptCategory);
dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
addList.add(dept);
return dept;
}
private void saveOrgs(List<Dept> addList, List<Dept> updateList, DeptCategory deptCategory) {
if (CollectionUtil.isNotEmpty(addList)) {
ComposeLogUtil.getLastLog().info("批量新增{}{}", deptCategory.getName(), addList.size());
deptService.saveBatch(addList);
}
if (CollectionUtil.isNotEmpty(updateList)) {
ComposeLogUtil.getLastLog().info("批量修改{}{}", deptCategory.getName(), updateList.size());
deptService.updateBatchById(updateList);
}
}
private Dept buildOrgDept(String name, String deptCode, Dept parent, String tenantId, DeptCategory deptCategory) {
Dept dept = new Dept();
dept.setTenantId(tenantId);
dept.setParentId(parent.getId());
dept.setParentCode(parent.getDeptCode());
dept.setAncestors(this.buildAncestors(parent));
dept.setDeptName(name);
dept.setFullName(name);
dept.setShortName(name);
dept.setDeptCode(deptCode);
dept.setBelongCompanyCode(DeptCategory.COMPANY.equals(deptCategory) ? deptCode : parent.getBelongCompanyCode());
dept.setDeptCategory(deptCategory.getCode());
dept.setSort(0);
dept.setStatus(DataStatusEnum.ENABLE.getCode());
dept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
dept.setIsOa(YES);
dept.setIsPlatformCompany(0);
dept.setSyncTime(new Date());
return dept;
}
private UserDept buildUserDept(OAPersonResponse oaPerson, Map<String, Long> userMap, OaOrgIndex orgIndex) {
Dept department = orgIndex.findDept(oaPerson);
if (department == null || department.getId() == null) {
return null;
}
UserDept userDept = userConvert.person2userDept(oaPerson, userMap);
if (userDept.getUserId() == null) {
return null;
}
Dept company = orgIndex.findCompany(oaPerson);
userDept.setDeptId(department.getId());
userDept.setDeptCode(department.getDeptCode());
userDept.setDeptName(department.getDeptName());
if (company != null) {
userDept.setCompanyCode(company.getDeptCode());
userDept.setCompanyName(company.getDeptName());
}
return userDept;
}
private String getDefaultRoleId() {
String defaultRole = ParamCache.getValue(DEFAULT_PARAM_ROLE);
if (defaultRole == null) {
defaultRole = DEFAULT_ROLE;
}
List<Role> roleList = roleService.list(Wrappers.<Role>lambdaQuery()
.eq(Role::getRoleAlias, defaultRole)
);
if (CollectionUtil.isEmpty(roleList)) {
return null;
}
return roleList.get(0).getId().toString();
}
private String resolveCompanyKey(OAPersonResponse oaPerson) {
if (oaPerson == null) {
return null;
}
if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) {
return oaPerson.getSubcompanyid1().trim();
}
if (StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
return "NAME:" + oaPerson.getSubcompanyname().trim();
}
return null;
}
private String resolveDepartmentKey(OAPersonResponse oaPerson) {
if (oaPerson == null) {
return null;
}
if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) {
return oaPerson.getDepartmentid().trim();
}
String companyKey = resolveCompanyKey(oaPerson);
if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())) {
return companyKey + ":" + oaPerson.getDepartmentname().trim();
}
return null;
}
private String buildCompanyCode(OAPersonResponse oaPerson, String companyKey) {
if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) {
return OAConvertConstant.COMPANY_OA_PREFIX + oaPerson.getSubcompanyid1().trim();
}
return OAConvertConstant.COMPANY_OA_PREFIX + "N" + Math.abs(companyKey.hashCode());
}
private String buildDepartmentCode(OAPersonResponse oaPerson, String departmentKey) {
if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) {
return OAConvertConstant.DEPARTMENT_OA_PREFIX + oaPerson.getDepartmentid().trim();
}
return OAConvertConstant.DEPARTMENT_OA_PREFIX + "N" + Math.abs(departmentKey.hashCode());
}
private String buildAncestors(Dept parent) {
String ancestors = parent.getAncestors();
if (StringUtils.isBlank(ancestors)) {
ancestors = String.valueOf(BladeConstant.TOP_PARENT_ID);
}
return ancestors + "," + parent.getId();
}
private String resolveTenantId() {
String tenantId = AuthUtil.getTenantId();
if (StringUtils.isBlank(tenantId)) {
return BladeConstant.ADMIN_TENANT_ID;
}
return tenantId;
}
private String getUserDeptKey(UserDept userDept) {
if (userDept == null) {
return null;
}
return userDept.getUserId() + userDept.getCompanyCode() + userDept.getDeptCode();
}
/**
* 本批人员同步计数
*/
public static class PersonSyncCount {
private final int syncedCount;
private final int skippedCount;
public PersonSyncCount(int syncedCount, int skippedCount) {
this.syncedCount = syncedCount;
this.skippedCount = skippedCount;
}
public int getSyncedCount() {
return syncedCount;
}
public int getSkippedCount() {
return skippedCount;
}
}
/**
* OA 组织索引
*/
public static class OaOrgIndex {
private final Map<String, Dept> companyByOaId = new HashMap<>();
private final Map<String, Dept> deptByOaId = new HashMap<>();
private final Map<String, Dept> deptByCompanyAndName = new HashMap<>();
private Dept findCompany(OAPersonResponse oaPerson) {
if (oaPerson == null) {
return null;
}
if (StringUtils.isNotBlank(oaPerson.getSubcompanyid1())) {
Dept company = companyByOaId.get(oaPerson.getSubcompanyid1().trim());
if (company != null) {
return company;
}
}
if (StringUtils.isNotBlank(oaPerson.getSubcompanyname())) {
return companyByOaId.get("NAME:" + oaPerson.getSubcompanyname().trim());
}
return null;
}
private Dept findDept(OAPersonResponse oaPerson) {
if (oaPerson == null) {
return null;
}
if (StringUtils.isNotBlank(oaPerson.getDepartmentid())) {
Dept department = deptByOaId.get(oaPerson.getDepartmentid().trim());
if (department != null) {
return department;
}
}
String companyKey = StringUtils.isNotBlank(oaPerson.getSubcompanyid1())
? oaPerson.getSubcompanyid1().trim()
: (StringUtils.isNotBlank(oaPerson.getSubcompanyname()) ? "NAME:" + oaPerson.getSubcompanyname().trim() : null);
if (StringUtils.isNotBlank(companyKey) && StringUtils.isNotBlank(oaPerson.getDepartmentname())) {
Dept department = deptByCompanyAndName.get(companyKey + "#" + oaPerson.getDepartmentname());
if (department != null) {
return department;
}
return deptByOaId.get(companyKey + ":" + oaPerson.getDepartmentname().trim());
}
return null;
}
}
}
@@ -0,0 +1,212 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.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;
import org.springblade.core.tool.api.R;
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.pojo.dto.PhoneChangeDTO;
import org.springblade.system.pojo.dto.PhoneVerifyDTO;
import org.springblade.system.pojo.entity.User;
import org.springblade.system.service.IUserPhoneService;
import org.springblade.system.service.IUserService;
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;
/**
* 用户手机号变更服务实现
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserPhoneServiceImpl implements IUserPhoneService {
/**
* 与登录短信一致,对应后台 /resource/sms 的 smsCode
*/
private static final String SMS_RESOURCE_CODE = "ali_reg";
/**
* 原手机号已校验凭证(Redis)
*/
private static final String PHONE_CHANGE_VERIFIED_KEY = "blade: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 ISmsClient smsClient;
private final BladeRedis bladeRedis;
@Override
public R sendCode(String phone) {
String normalizedPhone = normalizePhone(phone);
Long userId = AuthUtil.getUserId();
if (Func.isEmpty(userId)) {
throw new ServiceException("请先登录");
}
User currentUser = requireCurrentUser(userId);
String tenantId = Func.toStr(currentUser.getTenantId(), AuthUtil.getTenantId());
boolean isCurrentPhone = StringUtil.equals(normalizedPhone, Func.toStr(currentUser.getPhone()));
if (!isCurrentPhone) {
assertPhoneAvailable(tenantId, normalizedPhone, userId);
}
R result = smsClient.sendValidate(tenantId, SMS_RESOURCE_CODE, normalizedPhone);
if (result == null || !result.isSuccess()) {
return R.fail(SmsUtil.SEND_FAIL);
}
return R.data(result.getData(), SmsUtil.SEND_SUCCESS);
}
@Override
public boolean verifyOldPhone(PhoneVerifyDTO phoneVerify) {
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);
String oldPhone = Func.toStr(currentUser.getPhone()).trim();
if (StringUtil.isBlank(oldPhone)) {
throw new ServiceException("当前账号未绑定手机号");
}
validateSms(currentUser.getTenantId(), id, code, oldPhone);
bladeRedis.setEx(PHONE_CHANGE_VERIFIED_KEY + userId, "1", PHONE_CHANGE_VERIFIED_TTL);
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changePhone(PhoneChangeDTO phoneChange) {
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("请先完成原手机号验证");
}
String id = Func.toStr(phoneChange.getId()).trim();
String code = Func.toStr(phoneChange.getCode()).trim();
String newPhone = normalizePhone(phoneChange.getNewPhone());
if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) {
throw new ServiceException("请先获取并填写验证码");
}
User currentUser = requireCurrentUser(userId);
String oldPhone = Func.toStr(currentUser.getPhone()).trim();
if (StringUtil.equals(newPhone, oldPhone)) {
throw new ServiceException("新手机号不可与当前手机号相同");
}
String tenantId = Func.toStr(currentUser.getTenantId(), AuthUtil.getTenantId());
assertPhoneAvailable(tenantId, newPhone, userId);
validateSms(tenantId, id, code, newPhone);
User updateUser = new User();
updateUser.setId(userId);
updateUser.setPhone(newPhone);
// 账号若等于原手机号,同步更新,保证短信登录可用
if (StringUtil.isNotBlank(oldPhone) && StringUtil.equals(oldPhone, Func.toStr(currentUser.getAccount()))) {
assertAccountAvailable(tenantId, newPhone, userId);
updateUser.setAccount(newPhone);
}
boolean updated = userService.updateById(updateUser);
if (!updated) {
throw new ServiceException("手机号修改失败");
}
bladeRedis.del(PHONE_CHANGE_VERIFIED_KEY + userId);
CacheUtil.clear(USER_CACHE);
return true;
}
private User requireCurrentUser(Long userId) {
User user = userService.getById(userId);
if (user == null) {
throw new ServiceException("用户不存在");
}
return user;
}
private void validateSms(String tenantId, String id, String value, String phone) {
R result = smsClient.validateMessage(tenantId, SMS_RESOURCE_CODE, id, value, phone);
if (result == null || !result.isSuccess()) {
throw new ServiceException(SmsUtil.VALIDATE_FAIL);
}
}
private void assertPhoneAvailable(String tenantId, String phone, Long excludeUserId) {
Long phoneCount = userService.count(
Wrappers.<User>lambdaQuery()
.eq(User::getTenantId, tenantId)
.eq(User::getPhone, phone)
.ne(User::getId, excludeUserId)
);
if (phoneCount != null && phoneCount > 0L) {
throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", phone));
}
}
private void assertAccountAvailable(String tenantId, String account, Long excludeUserId) {
Long accountCount = userService.count(
Wrappers.<User>lambdaQuery()
.eq(User::getTenantId, tenantId)
.eq(User::getAccount, account)
.ne(User::getId, excludeUserId)
);
if (accountCount != null && accountCount > 0L) {
throw new ServiceException(StringUtil.format("当前用户 [{}] 已存在!", account));
}
}
private String normalizePhone(String phone) {
String normalizedPhone = Func.toStr(phone).trim();
if (!MOBILE_PATTERN.matcher(normalizedPhone).matches()) {
throw new ServiceException("手机号格式不正确");
}
return normalizedPhone;
}
}
@@ -529,6 +529,46 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
return userInfo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean bindWxMiniOpenId(String tenantId, Long userId, String openid, String phone) {
if (Func.isBlank(tenantId) || Func.isEmpty(userId) || Func.isBlank(openid)) {
throw new ServiceException("绑定微信 openid 参数不完整");
}
String source = "WECHAT_MINI";
UserOauth byOpenId = userOauthService.getOne(Wrappers.<UserOauth>lambdaQuery()
.eq(UserOauth::getTenantId, tenantId)
.eq(UserOauth::getSource, source)
.eq(UserOauth::getUuid, openid)
.last("LIMIT 1"));
if (byOpenId != null) {
byOpenId.setUserId(userId);
if (Func.isNotBlank(phone)) {
byOpenId.setUsername(phone);
}
return userOauthService.updateById(byOpenId);
}
UserOauth byUser = userOauthService.getOne(Wrappers.<UserOauth>lambdaQuery()
.eq(UserOauth::getTenantId, tenantId)
.eq(UserOauth::getSource, source)
.eq(UserOauth::getUserId, userId)
.last("LIMIT 1"));
if (byUser != null) {
byUser.setUuid(openid);
if (Func.isNotBlank(phone)) {
byUser.setUsername(phone);
}
return userOauthService.updateById(byUser);
}
UserOauth oauth = new UserOauth();
oauth.setTenantId(tenantId);
oauth.setUserId(userId);
oauth.setUuid(openid);
oauth.setUsername(Func.toStr(phone, ""));
oauth.setSource(source);
return userOauthService.save(oauth);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean grant(String userIds, String roleIds) {
@@ -30,7 +30,6 @@ 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.entity.ExceptionDisposal;
@@ -48,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
* <p>
* 对外路径:{@code /api/blade-transport/exception-disposal/**}
* 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。
* 司机上报(submit)/ 列表 / 详情仅需登录态;跟进与完成保留菜单鉴权
* 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)
*/
@RestController
@AllArgsConstructor
@@ -80,27 +79,24 @@ public class ExceptionDisposalController extends BladeController {
}
@PostMapping("/follow")
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 4)
@Operation(summary = "异常跟进")
@Operation(summary = "异常跟进", description = "调度端跟进;仅需登录态")
public R follow(@RequestBody ExceptionDisposalFollowRequest request) {
exceptionDisposalService.follow(request);
return R.success("跟进成功");
}
@PostMapping("/complete")
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 5)
@Operation(summary = "完成异常")
@Operation(summary = "完成异常", description = "调度端结案;仅需登录态")
public R complete(@RequestBody ExceptionDisposalFollowRequest request) {
exceptionDisposalService.complete(request.getId());
return R.success("完成成功");
}
@PostMapping("/batch-complete")
@PreAuth(menu = "exception_disposal")
@ApiOperationSupport(order = 6)
@Operation(summary = "批量完成异常")
@Operation(summary = "批量完成异常", description = "调度端批量结案;仅需登录态")
public R batchComplete(@RequestParam String ids) {
exceptionDisposalService.batchComplete(ids);
return R.success("批量完成成功");
@@ -0,0 +1,143 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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.tool.api.R;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeStatsVO;
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.service.IManageWaybillService;
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.List;
/**
* 调度端运单(小程序管理端)
* <p>
* 对外完整路径:{@code /api/blade-transport/waybill/manage/**}
* (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /waybill/manage/**})。
* 同时兼容未去前缀直连({@code /blade-transport/waybill/manage/**})。
* 仅需登录态,不挂管理端菜单鉴权。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/waybill/manage", "/blade-transport/waybill/manage"})
@Tag(name = "调度端运单", description = "小程序调度端首页统计与运单列表")
public class ManageWaybillController extends BladeController {
private final IManageWaybillService manageWaybillService;
@GetMapping("/stats")
@ApiOperationSupport(order = 1)
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
public R<AdminHomeStatsVO> stats() {
return R.data(manageWaybillService.stats());
}
@GetMapping("/home")
@ApiOperationSupport(order = 2)
@Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名")
public R<AdminHomeVO> home() {
return R.data(manageWaybillService.home());
}
@GetMapping("/list")
@ApiOperationSupport(order = 3)
@Operation(summary = "运单分页列表", description = "当前组织运单;status:0待接单/1运输中/2已完成;exceptionexception/normaltransportTypecommon/load")
public R<IPage<AdminWaybillCardVO>> list(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
@Parameter(description = "异常:exception有异常/normal无异常") @RequestParam(required = false) String exception,
@Parameter(description = "运输组织:common普通/load配载") @RequestParam(required = false) String transportType,
@Parameter(description = "创建日起 YYYY-MM-DD") @RequestParam(required = false) String startDate,
@Parameter(description = "创建日止 YYYY-MM-DD") @RequestParam(required = false) String endDate) {
return R.data(manageWaybillService.pageList(
current, size, keyword, status, exception, transportType, startDate, endDate));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
public R<AdminWaybillDetailVO> detail(
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.data(manageWaybillService.detail(id));
}
@GetMapping("/pending")
@ApiOperationSupport(order = 5)
@Operation(summary = "待处理运单", description = "待接单/运输中;needReassign=true 仅司机已拒单")
public R<IPage<AdminWaybillCardVO>> pending(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword,
@Parameter(description = "是否需重新派单") @RequestParam(required = false) Boolean needReassign) {
return R.data(manageWaybillService.pendingList(current, size, keyword, needReassign));
}
@PostMapping("/reassign")
@ApiOperationSupport(order = 6)
@Operation(summary = "重新派单", description = "小程序调度端:跳过组织校验,仅需登录态;传入运单ID及新司机、手机号、车牌")
public R reassign(@RequestBody Waybill waybill) {
return R.status(manageWaybillService.reassign(
waybill.getId(),
waybill.getDriverId(),
waybill.getDriverName(),
waybill.getDriverPhone(),
waybill.getVehicleNo()));
}
@GetMapping("/driver-search")
@ApiOperationSupport(order = 7)
@Operation(summary = "搜索司机", description = "按姓名/手机号模糊搜索,供重新派单选用")
public R<List<AdminDriverOptionVO>> driverSearch(
@Parameter(description = "关键字") @RequestParam(required = false) String keyword) {
return R.data(manageWaybillService.searchDrivers(keyword));
}
@GetMapping("/vehicle-search")
@ApiOperationSupport(order = 8)
@Operation(summary = "搜索车牌", description = "按车牌模糊搜索(来自司机绑定车牌)")
public R<List<AdminVehicleOptionVO>> vehicleSearch(
@Parameter(description = "关键字") @RequestParam(required = false) String keyword) {
return R.data(manageWaybillService.searchVehicles(keyword));
}
}
@@ -68,6 +68,12 @@ public interface IDriverWaybillService {
*/
DriverWaybillCardVO detail(Long id);
/**
* 按运单ID组装详情打卡数据(punchNodes / enrouteRecords),不校验当前登录人是否为该司机。
* 供调度端 manage/detail 复用。
*/
DriverWaybillCardVO detailPunchSnapshot(Long id);
/**
* 司机确认接单:过程配置要求接单且尚未接单时,写入接单记录并将运单改为进行中。
*/
@@ -0,0 +1,90 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeStatsVO;
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 java.util.List;
/**
* 调度端(小程序管理端)运单首页服务
*/
public interface IManageWaybillService {
/**
* 运单状态统计:运输中 / 待接单 / 在途异常 / 已完成
*/
AdminHomeStatsVO stats();
/**
* 首页聚合:统计 + 角标 + 待处理事项(异常处置≠已完成)+ 用户名
*/
AdminHomeVO home();
/**
* 调度端运单分页列表
*
* @param current 页码
* @param size 每页条数
* @param keyword 运单号/司机/车牌
* @param status 0待接单/1运输中/2已完成,空=全部
* @param exception exception有异常 / normal无异常 / 空=全部
* @param transportType common普通 / load配载 / 空=全部
* @param startDate 创建日起 YYYY-MM-DD
* @param endDate 创建日止 YYYY-MM-DD
*/
IPage<AdminWaybillCardVO> pageList(Integer current, Integer size, String keyword, String status,
String exception, String transportType, String startDate, String endDate);
/**
* 调度端运单详情(不校验司机归属)
*/
AdminWaybillDetailVO detail(Long id);
/**
* 待处理运单(待接单 / 运输中;可筛需重新派单)
*/
IPage<AdminWaybillCardVO> pendingList(Integer current, Integer size, String keyword, Boolean needReassign);
/**
* 重新派单:跳过管理端部门校验,仅需登录态(司机、手机号、车牌)
*/
boolean reassign(Long id, Long driverId, String driverName, String driverPhone, String vehicleNo);
/**
* 搜索司机(姓名/手机号)
*/
List<AdminDriverOptionVO> searchDrivers(String keyword);
/**
* 搜索车牌(来自司机绑定车牌)
*/
List<AdminVehicleOptionVO> searchVehicles(String keyword);
}
@@ -60,6 +60,12 @@ public interface IWaybillService extends BaseService<Waybill> {
boolean maintainMileage(WaybillMileageRequest request);
boolean cancel(Long id);
boolean reassign(Waybill waybill);
/**
* 小程序调度端重新派单:跳过管理端部门校验,其余逻辑与 {@link #reassign(Waybill)} 一致。
*/
boolean reassignWithoutDeptCheck(Waybill waybill);
boolean complete(Long id);
/**
@@ -225,6 +225,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
public DriverWaybillCardVO detailPunchSnapshot(Long id) {
if (id == null) {
throw new ServiceException("运单ID不能为空");
}
Waybill waybill = waybillService.getById(id);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单不存在");
}
return toCard(normalizeAcceptStatus(waybill), true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public DriverEnrouteRecordVO submitEnroute(EnrouteSubmitDTO dto) {
@@ -610,6 +622,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setAcceptStatus(waybill.getDriverAcceptStatus());
card.setRejectReason(waybill.getDriverRejectReason());
// 详情页字段(列表也可带上,体积很小)
String cargoName = Func.toStr(waybill.getCargoName(), "");
String weightText = card.getWeight();
card.setCargoName(cargoName);
card.setPickupAddress(card.getFromAddress());
card.setUnloadAddress(card.getToAddress());
card.setCargoQuantity(weightText);
card.setTotalWeight(weightText);
card.setTransportType(toTransportTypeLabel(waybill.getTransportType()));
card.setPlanShipTime(formatLocalDateYmd(waybill.getEstimatedStartTime()));
card.setPlanFinishTime(formatLocalDateYmd(waybill.getEstimatedEndTime()));
card.setRemark(Func.toStr(waybill.getRemark(), ""));
if (withEnrouteRecords) {
Date lastPunchAt = findLastPunchTime(waybill.getId());
WaybillProcessSupport.TransitCheckinDecision transit = WaybillProcessSupport.evaluateTransitCheckin(
@@ -623,6 +648,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setTransitTimeEnd(transit.timeEnd());
card.setEnrouteRecords(listEnrouteRecords(waybill.getId()));
card.setPunchNodes(buildPunchNodes(waybill, transit, processJson));
card.setRoutePoints(buildSimpleRoutePoints(waybill));
card.setProcessJson(processJson);
} else {
// 列表/首页:只解析过程配置是否启用在途打卡,不做频次/时段与落库查询
boolean punchEnabled = WaybillProcessSupport.isTransitPunchEnabled(processJson);
@@ -634,18 +661,61 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return card;
}
private List<DriverWaybillCardVO.DriverRoutePointVO> buildSimpleRoutePoints(Waybill waybill) {
DriverWaybillCardVO.DriverRoutePointVO load = new DriverWaybillCardVO.DriverRoutePointVO();
load.setName(Func.toStr(waybill.getDepartureName(), "装货点"));
load.setAddress(Func.toStr(waybill.getDepartureAddress(), load.getName()));
load.setStatus("pending");
DriverWaybillCardVO.DriverRoutePointVO unload = new DriverWaybillCardVO.DriverRoutePointVO();
unload.setName(Func.toStr(waybill.getArrivalName(), "卸货点"));
unload.setAddress(Func.toStr(waybill.getArrivalAddress(), unload.getName()));
unload.setStatus("pending");
return List.of(load, unload);
}
private String toTransportTypeLabel(String transportType) {
if (Func.isBlank(transportType)) {
return "";
}
String t = transportType.trim().toLowerCase();
return switch (t) {
case "road", "gl" -> "公路运输";
case "railway", "rail" -> "铁路运输";
case "river", "water", "waterway" -> "水路运输";
case "air", "aviation" -> "航空运输";
default -> transportType;
};
}
private String formatLocalDateYmd(LocalDate date) {
if (date == null) {
return "";
}
return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
/**
* 动态获取项目启用中的过程配置节点 JSON;无则回退运单快照 processJson
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照 processJson
* 避免项目配置改坏后司机端打卡页空白。
*/
private String resolveProcessJson(Waybill waybill) {
if (waybill == null) {
return null;
}
String snapshot = waybill.getProcessJson();
String live = loadLiveProcessConfigJson(waybill.getProjectId());
if (Func.isNotEmpty(live)) {
if (!WaybillProcessSupport.listDriverPunchNodes(live).isEmpty()) {
return live;
}
// 动态配置存在但无可打卡节点:仍回退快照
if (Func.isNotEmpty(snapshot)
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
return snapshot;
}
return live;
}
return waybill.getProcessJson();
return snapshot;
}
private String loadLiveProcessConfigJson(Long projectId) {
@@ -0,0 +1,642 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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 com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.RiskDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
import org.springblade.transport.pojo.vo.AdminHomeBadgesVO;
import org.springblade.transport.pojo.vo.AdminHomeStatsVO;
import org.springblade.transport.pojo.vo.AdminHomeVO;
import org.springblade.transport.pojo.vo.AdminTodoItemVO;
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.DriverWaybillCardVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IManageWaybillService;
import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.IWaybillService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
* <p>
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
*/
@Service
@RequiredArgsConstructor
public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final String STATUS_PENDING = "pending";
private static final String STATUS_RUNNING = "running";
private static final String STATUS_COMPLETED = "completed";
private static final String STATUS_CANCELLED = "cancelled";
private static final String ACCEPT_REJECTED = "rejected";
private static final String DISPOSAL_PENDING = "pending";
private static final String DISPOSAL_PROCESSING = "processing";
private static final String RISK_PENDING = "pending";
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
private static final String TRANSPORT_COMMON = "common";
private static final String TRANSPORT_LOAD = "load";
private static final int FEED_LIMIT = 20;
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IExceptionDisposalService exceptionDisposalService;
private final IRiskDisposalService riskDisposalService;
private final IDriverWaybillService driverWaybillService;
@Override
public AdminHomeStatsVO stats() {
AdminHomeStatsVO vo = new AdminHomeStatsVO();
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
vo.setException(countIncompleteExceptions());
return vo;
}
@Override
public AdminHomeVO home() {
AdminHomeVO home = new AdminHomeVO();
home.setUserName(resolveUserName());
home.setStats(stats());
AdminHomeBadgesVO badges = new AdminHomeBadgesVO();
badges.setException(home.getStats().getException());
badges.setRisk(countPendingRisks());
home.setBadges(badges);
home.setFeed(buildExceptionFeed());
return home;
}
@Override
public IPage<AdminWaybillCardVO> pageList(Integer current, Integer size, String keyword, String status,
String exception, String transportType, String startDate, String endDate) {
int pageNo = current == null || current < 1 ? 1 : current;
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
emptyVo.setRecords(List.of());
return emptyVo;
}
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
applyStatusFilter(wrapper, status);
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
applyTransportTypeFilter(wrapper, transportType);
applyKeywordFilter(wrapper, keyword);
applyCreateTimeFilter(wrapper, startDate, endDate);
wrapper.orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
}
@Override
public AdminWaybillDetailVO detail(Long id) {
if (id == null) {
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
}
Waybill waybill = waybillService.getById(id);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
}
boolean hasException = false;
Long exceptionId = null;
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
.eq(ExceptionDisposal::getWaybillId, id)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.orderByDesc(ExceptionDisposal::getCreateTime)
.last("LIMIT 1"));
if (latest != null) {
hasException = true;
exceptionId = latest.getId();
}
return toDetail(waybill, hasException, exceptionId);
}
@Override
public IPage<AdminWaybillCardVO> pendingList(Integer current, Integer size, String keyword, Boolean needReassign) {
int pageNo = current == null || current < 1 ? 1 : current;
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
if (Boolean.TRUE.equals(needReassign)) {
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
} else if (Boolean.FALSE.equals(needReassign)) {
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
}
applyKeywordFilter(wrapper, keyword);
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
List<Long> pageIds = entityPage.getRecords().stream()
.map(Waybill::getId)
.filter(Objects::nonNull)
.toList();
Set<Long> pageExceptionIds = pageIds.isEmpty()
? Collections.emptySet()
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
voPage.setRecords(entityPage.getRecords().stream()
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
.toList());
return voPage;
}
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
detail.setId(waybill.getId());
detail.setWaybillNo(waybill.getWaybillNo());
detail.setStatus(toAppStatus(waybill.getBusinessStatus()));
String mode = Func.toStr(waybill.getTransportType(), "");
detail.setTransportMode(mode);
detail.setTransportType(toTransportTypeLabel(mode));
detail.setTransportOrgType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON);
detail.setFromName(formatPlaceName(waybill.getDepartureName(), mode));
detail.setToName(formatPlaceName(waybill.getArrivalName(), mode));
String fromAddr = Func.toStr(waybill.getDepartureAddress(), Func.toStr(waybill.getDepartureName(), ""));
String toAddr = Func.toStr(waybill.getArrivalAddress(), Func.toStr(waybill.getArrivalName(), ""));
detail.setFromAddress(fromAddr);
detail.setToAddress(toAddr);
detail.setPickupAddress(fromAddr);
detail.setUnloadAddress(toAddr);
String cargo = Func.toStr(waybill.getCargoName(), "");
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
detail.setCargoName(cargo);
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
detail.setPlanShipTime(formatLocalDate(waybill.getEstimatedStartTime()));
detail.setPlanFinishTime(formatLocalDate(waybill.getEstimatedEndTime()));
detail.setCarrierName(Func.toStr(waybill.getCarrierName(), ""));
detail.setDriverName(Func.toStr(waybill.getDriverName(), ""));
detail.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
detail.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
detail.setRemark(Func.toStr(waybill.getRemark(), ""));
detail.setHasException(hasException);
detail.setExceptionId(exceptionId);
detail.setNeedReassign(ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus()));
detail.setAcceptStatus(Func.toStr(waybill.getDriverAcceptStatus(), ""));
detail.setRejectReason(Func.toStr(waybill.getDriverRejectReason(), ""));
detail.setDriverId(waybill.getDriverId());
AdminWaybillDetailVO.AdminRoutePointVO load = new AdminWaybillDetailVO.AdminRoutePointVO();
load.setName(Func.toStr(waybill.getDepartureName(), "装货点"));
load.setAddress(fromAddr);
load.setStatus("pending");
AdminWaybillDetailVO.AdminRoutePointVO unload = new AdminWaybillDetailVO.AdminRoutePointVO();
unload.setName(Func.toStr(waybill.getArrivalName(), "卸货点"));
unload.setAddress(toAddr);
unload.setStatus("pending");
detail.setRoutePoints(List.of(load, unload));
// 复用司机端打卡组装:过程节点 + 途打卡记录(调度端只读展示)
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybill.getId());
if (punch != null) {
detail.setPunchNodes(punch.getPunchNodes());
detail.setEnrouteRecords(punch.getEnrouteRecords());
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
rp.setName(p.getName());
rp.setAddress(p.getAddress());
rp.setStatus(p.getStatus());
return rp;
}).toList());
}
}
return detail;
}
@Override
public boolean reassign(Long id, Long driverId, String driverName, String driverPhone, String vehicleNo) {
Waybill request = new Waybill();
request.setId(id);
request.setDriverId(driverId);
request.setDriverName(driverName);
request.setDriverPhone(driverPhone);
request.setVehicleNo(vehicleNo);
return waybillService.reassignWithoutDeptCheck(request);
}
@Override
public List<AdminDriverOptionVO> searchDrivers(String keyword) {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 20");
if (Func.isNotBlank(key)) {
wrapper.and(w -> w.like(Driver::getDriverName, key).or().like(Driver::getMobile, key));
}
return driverService.list(wrapper).stream().map(d -> {
AdminDriverOptionVO vo = new AdminDriverOptionVO();
vo.setId(d.getId());
vo.setName(Func.toStr(d.getDriverName(), ""));
vo.setPhone(Func.toStr(d.getMobile(), ""));
vo.setVehicleNo(Func.toStr(d.getDrivingVehicle(), ""));
return vo;
}).toList();
}
@Override
public List<AdminVehicleOptionVO> searchVehicles(String keyword) {
String key = Func.toStr(keyword, "").trim();
LambdaQueryWrapper<Driver> wrapper = Wrappers.<Driver>lambdaQuery()
.eq(Driver::getIsDeleted, 0)
.isNotNull(Driver::getDrivingVehicle)
.ne(Driver::getDrivingVehicle, "")
.orderByDesc(Driver::getUpdateTime)
.last("LIMIT 30");
if (Func.isNotBlank(key)) {
wrapper.like(Driver::getDrivingVehicle, key);
}
java.util.LinkedHashMap<String, AdminVehicleOptionVO> map = new java.util.LinkedHashMap<>();
for (Driver d : driverService.list(wrapper)) {
String plate = Func.toStr(d.getDrivingVehicle(), "").trim();
if (Func.isBlank(plate) || map.containsKey(plate)) {
continue;
}
AdminVehicleOptionVO vo = new AdminVehicleOptionVO();
vo.setVehicleNo(plate);
vo.setDriverName(Func.toStr(d.getDriverName(), ""));
map.put(plate, vo);
}
return new java.util.ArrayList<>(map.values());
}
/** 运输方式字典值 → 展示文案 */
private String toTransportTypeLabel(String transportType) {
if (Func.isBlank(transportType)) {
return "";
}
String t = transportType.trim().toLowerCase();
return switch (t) {
case "road", "gl" -> "公路运输";
case "railway", "rail" -> "铁路运输";
case "river", "water", "waterway" -> "水路运输";
case "air", "aviation" -> "航空运输";
default -> transportType;
};
}
private long countWaybillByStatus(String status) {
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getBusinessStatus, status));
}
/**
* 小程序调度端不做组织过滤。
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
*/
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
return Wrappers.<Waybill>lambdaQuery();
}
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
String businessStatus = toBusinessStatus(status);
if (Func.isNotBlank(businessStatus)) {
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
}
}
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
if (EXCEPTION_YES.equals(exception)) {
wrapper.in(Waybill::getId, exceptionWaybillIds);
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
}
}
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
if (TRANSPORT_LOAD.equals(transportType)) {
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
} else if (TRANSPORT_COMMON.equals(transportType)) {
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
}
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
.or().like(Waybill::getDriverName, key)
.or().like(Waybill::getVehicleNo, key));
}
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
if (Func.isNotBlank(startDate)) {
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
if (start != null) {
wrapper.ge(Waybill::getCreateTime, start);
}
}
if (Func.isNotBlank(endDate)) {
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
if (end != null) {
wrapper.le(Waybill::getCreateTime, end);
}
}
}
/** 小程序 status → 后端 businessStatus */
private String toBusinessStatus(String status) {
if (Func.isBlank(status)) {
return null;
}
return switch (status.trim()) {
case "0", STATUS_PENDING -> STATUS_PENDING;
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
default -> null;
};
}
private Set<Long> loadIncompleteExceptionWaybillIds() {
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
.select(ExceptionDisposal::getWaybillId)
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.isNotNull(ExceptionDisposal::getWaybillId));
Set<Long> ids = new HashSet<>();
for (ExceptionDisposal item : list) {
if (item.getWaybillId() != null) {
ids.add(item.getWaybillId());
}
}
return ids;
}
private AdminWaybillCardVO toCard(Waybill waybill, boolean hasException) {
AdminWaybillCardVO card = new AdminWaybillCardVO();
card.setId(waybill.getId());
card.setWaybillNo(waybill.getWaybillNo());
String mode = Func.toStr(waybill.getTransportType(), "");
card.setTransportMode(mode);
card.setFromName(formatPlaceName(waybill.getDepartureName(), mode));
card.setToName(formatPlaceName(waybill.getArrivalName(), mode));
card.setCargo(Func.toStr(waybill.getCargoName(), ""));
card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
card.setPlanTime(formatLocalDate(waybill.getEstimatedStartTime()));
card.setPlanTimeEnd(formatLocalDate(waybill.getEstimatedEndTime()));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
card.setCarrierName(Func.toStr(waybill.getCarrierName(), ""));
card.setDriverName(Func.toStr(waybill.getDriverName(), ""));
card.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
card.setHasException(hasException);
card.setTransportType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON);
card.setCreateTime(formatDateTime(waybill.getCreateTime()));
card.setNeedReassign(ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus()));
card.setBuyerPaid(false);
return card;
}
/**
* 公路运输:起/终仅展示市县(去掉省/自治区);其它运输方式原样返回。
*/
private String formatPlaceName(String name, String transportType) {
String raw = Func.toStr(name, "").trim();
if (Func.isBlank(raw) || !isRoadTransport(transportType)) {
return raw;
}
return toCityCounty(raw);
}
private boolean isRoadTransport(String transportType) {
if (Func.isBlank(transportType)) {
return false;
}
String t = transportType.trim().toLowerCase();
return t.contains("road") || transportType.contains("公路") || transportType.contains("道路") || "gl".equals(t);
}
/** 去掉省级前缀,保留「市 + 区/县/旗」 */
private String toCityCounty(String name) {
String s = name.replaceFirst("^.+?(省|自治区|特别行政区)", "");
if (Func.isBlank(s)) {
s = name;
}
java.util.regex.Matcher city = java.util.regex.Pattern
.compile("^(.+?市)(.+?(?:区|县|旗|市))?")
.matcher(s);
if (city.find()) {
return Func.toStr(city.group(1), "") + Func.toStr(city.group(2), "");
}
java.util.regex.Matcher prefecture = java.util.regex.Pattern
.compile("^(.+?(?:州|盟|地区))(.+?(?:区|县|旗|市))?")
.matcher(s);
if (prefecture.find()) {
return Func.toStr(prefecture.group(1), "") + Func.toStr(prefecture.group(2), "");
}
return s;
}
private Integer toAppStatus(String businessStatus) {
if (Func.isBlank(businessStatus)) {
return null;
}
return switch (businessStatus) {
case STATUS_PENDING, "waiting_dispatch", "dispatching" -> 0;
case STATUS_RUNNING -> 1;
case STATUS_COMPLETED -> 2;
case STATUS_CANCELLED -> 3;
default -> null;
};
}
private String formatWeight(BigDecimal quantity, String unit) {
if (quantity == null) {
return "";
}
String qty = quantity.stripTrailingZeros().toPlainString();
return Func.isBlank(unit) ? qty : qty + unit;
}
private String formatLocalDate(LocalDate date) {
if (date == null) {
return "";
}
return date.format(DATE_YMD);
}
private String formatDateTime(Date date) {
if (date == null) {
return "";
}
return DateUtil.format(date, DateUtil.PATTERN_DATETIME);
}
private long countIncompleteExceptions() {
return exceptionDisposalService.count(Wrappers.<ExceptionDisposal>lambdaQuery()
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING));
}
private long countPendingRisks() {
return riskDisposalService.count(Wrappers.<RiskDisposal>lambdaQuery()
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
}
private List<AdminTodoItemVO> buildExceptionFeed() {
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
.orderByDesc(ExceptionDisposal::getReportTime)
.last("LIMIT " + FEED_LIMIT));
return list.stream().map(this::toTodoItem).collect(Collectors.toList());
}
private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) {
AdminTodoItemVO item = new AdminTodoItemVO();
item.setId(disposal.getId());
item.setType("exception");
item.setTitle("异常待处置");
item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null
? disposal.getReportTime()
: toLocalDateTime(disposal.getCreateTime())));
item.setDesc(buildExceptionDesc(disposal));
item.setWaybillNo(Func.toStr(disposal.getWaybillNo(), ""));
item.setActionLabel("立即处置");
String status = Func.toStr(disposal.getDisposalStatus(), DISPOSAL_PENDING);
item.setTargetUrl("/subpackages/admin/exception?status=" + status);
return item;
}
private String buildExceptionDesc(ExceptionDisposal disposal) {
String reporter = Func.toStr(disposal.getReporterName(), "司机");
String type = Func.toStr(disposal.getExceptionType(), "异常");
String reason = Func.isNotBlank(disposal.getExceptionReason())
? disposal.getExceptionReason()
: Func.toStr(disposal.getReportDescription(), "");
if (Func.isBlank(reason)) {
return reporter + "上报" + type;
}
String text = reporter + "上报" + type + "" + reason.trim();
return text.length() > 80 ? text.substring(0, 80) + "" : text;
}
private String resolveUserName() {
String realName = UserCache.getUserRealName(AuthUtil.getUserId());
if (Func.isNotBlank(realName)) {
return realName;
}
return Func.toStr(AuthUtil.getUserName(), "");
}
private String formatTimeAgo(LocalDateTime time) {
if (time == null) {
return "";
}
Duration duration = Duration.between(time, LocalDateTime.now());
if (duration.isNegative()) {
duration = Duration.ZERO;
}
long minutes = duration.toMinutes();
if (minutes < 1) {
return "刚刚";
}
if (minutes < 60) {
return minutes + "分钟";
}
long hours = duration.toHours();
if (hours < 24) {
return hours + "小时";
}
long days = duration.toDays();
if (days < 30) {
return days + "";
}
return time.format(DATE_MD);
}
private LocalDateTime toLocalDateTime(Date date) {
if (date == null) {
return null;
}
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime();
}
}
@@ -819,10 +819,20 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reassign(Waybill request) {
return doReassign(request, true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reassignWithoutDeptCheck(Waybill request) {
return doReassign(request, false);
}
private boolean doReassign(Waybill request, boolean checkDept) {
if (request == null || Func.isEmpty(request.getId())) {
throw new ServiceException("运单ID不能为空");
}
Waybill waybill = loadEditable(request.getId(), true);
Waybill waybill = loadEditable(request.getId(), checkDept);
assertNotLoaded(waybill);
if (!"pending".equals(waybill.getBusinessStatus()) && !"running".equals(waybill.getBusinessStatus())) {
throw new ServiceException("仅待执行/进行中运单允许重新派单");