build / build-push (push) Successful in 1m30s
- 已办去重: 同一办件一行 + doneCount 累计次数(FlwTaskRepository 查询去重)
- 发起/撤回/重提留痕事件行(状态码 6/7, 无需 DDL); /oapi/flow/progress steps 含撤回/重提步骤
- 管理端新增启用回路: POST /admin/map/enable/{id}、POST /admin/def/enable/{id}; GET /api/flow/def/source 源码查询
- 修复 findEnabledByOrdId 未过滤 status 的隐性缺陷; 新增 DeptLabelSupport 科室标签支撑
- 测试: FlowEngineIT 撤回留痕/已办去重断言、OapiFlowContractIT 撤回步骤断言(94 tests 全过)
- 本地验签修复: 本地 UserBase 无 context-path, yh.open.services 显式覆盖服务目录枚举兜底的 /userbase basePath, 否则出站签名与服务端剥离不对齐 → GetUserInfo 等全部「验签失败」; 生产经 Caddy 挂 /userbase 时删除 services 覆盖
419 lines
18 KiB
Java
419 lines
18 KiB
Java
package com.yunhai.flowcenter.service;
|
||
|
||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||
import com.yunhai.platform.apistd.BizException;
|
||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||
import com.yunhai.platform.utils.model.BoaUser;
|
||
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
|
||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||
import com.yunhai.platform.utils.model.BoaGroup;
|
||
import lombok.Getter;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.Setter;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.stereotype.Service;
|
||
import org.springframework.transaction.annotation.Transactional;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.Collections;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人显式映射,成员检索经
|
||
* UserBase /oapi/user/byId 补全姓名快照(检索按 id 精确查;按名检索二期接 UserBase 模糊端点)。
|
||
* <p>
|
||
* 2026-09-16 新增「一键拉取教师组」:按 {@code dept-group-type}(默认 7=教师组,与 svc-hall
|
||
* {@code svchall.item.dept-group-type} 同口径)从 UserBase 学校组子树取科室容器,其子组即科室
|
||
* (容器无子组时容器本身即科室),deptKey=UserBase 组 id;成员=组内用户(不含子组)。
|
||
* 批量落 flw_assignee_map,替代管理端逐人录入。
|
||
*/
|
||
@Slf4j
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class FlowMapAdminService {
|
||
|
||
private final FlwAssigneeMapRepository mapRepository;
|
||
private final OpenApiUserClient userClient;
|
||
private final OpenApiGroupClient groupClient;
|
||
|
||
/** 科室"容器组"类型(UserBase GroupTypeEnum,默认 7=教师组;口径承袭 svc-hall) */
|
||
@Value("${flow-center.map.dept-group-type:7}")
|
||
private int deptGroupType = 7;
|
||
|
||
/** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),选人组树中整棵剔除(口径同 Notice) */
|
||
private static final int GROUP_TYPE_STUDENT = 8;
|
||
|
||
public List<FlwAssigneeMap> listByDept(Long schId, String deptKey) {
|
||
if (deptKey == null || deptKey.trim().isEmpty()) {
|
||
// 全量(含停用行):管理端要能看到停用行才能点"启用"恢复
|
||
return mapRepository.findAllBySchId(schId);
|
||
}
|
||
return mapRepository.findBySchIdAndDeptKeyOrderByUserIdAsc(schId, deptKey.trim());
|
||
}
|
||
|
||
/** 新增/启用映射(uk: sch+dept+user;已存在则刷新姓名/状态) */
|
||
@Transactional
|
||
public FlwAssigneeMap save(Long schId, String deptKey, String deptName,
|
||
Integer userId, String userName) {
|
||
if (deptKey == null || deptKey.trim().isEmpty() || userId == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "deptKey/userId 必填");
|
||
}
|
||
if (userName == null || userName.trim().isEmpty()) {
|
||
userName = fetchName(userId);
|
||
}
|
||
FlwAssigneeMap map = mapRepository.findBySchIdAndDeptKeyAndUserId(schId, deptKey.trim(), userId);
|
||
if (map == null) {
|
||
map = new FlwAssigneeMap();
|
||
map.setSchId(schId);
|
||
map.setDeptKey(deptKey.trim());
|
||
map.setUserId(userId);
|
||
}
|
||
map.setDeptName(deptName);
|
||
map.setUserName(userName);
|
||
map.setStatus(0);
|
||
return mapRepository.save(map);
|
||
}
|
||
|
||
/** 停用映射(保留历史,DEPT_KEY 解析只取 status=0) */
|
||
@Transactional
|
||
public void disable(Long schId, Long id) {
|
||
FlwAssigneeMap map = mapRepository.findById(id)
|
||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "映射不存在:" + id));
|
||
if (!map.getSchId().equals(schId)) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "映射不属于该学校");
|
||
}
|
||
map.setStatus(1);
|
||
mapRepository.save(map);
|
||
}
|
||
|
||
/** 启用映射(2026-09-18 补齐:原实现只有停用,停用后只能重录一条 sch+dept+user 来恢复) */
|
||
@Transactional
|
||
public FlwAssigneeMap enable(Long schId, Long id) {
|
||
FlwAssigneeMap map = mapRepository.findById(id)
|
||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "映射不存在:" + id));
|
||
if (!map.getSchId().equals(schId)) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "映射不属于该学校");
|
||
}
|
||
map.setStatus(0);
|
||
return mapRepository.save(map);
|
||
}
|
||
|
||
/** 成员检索:按用户 id 从 UserBase 取姓名快照(管理端录入校验) */
|
||
public BoaUser lookupUser(Integer userId) {
|
||
try {
|
||
return userClient.getUserById(String.valueOf(userId));
|
||
} catch (Exception e) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 用户检索失败(服务不可达)");
|
||
}
|
||
}
|
||
|
||
// ==================== 选人组件数据源 ====================
|
||
|
||
/** 选人组件树节点:组=isParent(true)+children;用户=叶子(isParent 缺省 false) */
|
||
@Getter
|
||
@Setter
|
||
public static class SelectUserNodeVo {
|
||
private Integer id;
|
||
private String name;
|
||
private Boolean isParent;
|
||
private List<SelectUserNodeVo> children = new ArrayList<>();
|
||
}
|
||
|
||
/**
|
||
* 学校组树(组→组内用户叶子),供管理端「选择用户」弹窗(多选指定用户/单选映射办理人)。
|
||
* 与 {@link #previewGroupDepts(Long)} 同源:UserBase getGroupById(schId, withSub) 子树 + 逐组挂成员。
|
||
*/
|
||
public List<SelectUserNodeVo> userTree(Long schId) {
|
||
BoaGroup subtree;
|
||
try {
|
||
subtree = groupClient.getGroupById(schId.intValue(), true);
|
||
} catch (Exception e) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
|
||
}
|
||
if (subtree == null || subtree.getId() == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
|
||
}
|
||
return Collections.singletonList(groupNode(subtree));
|
||
}
|
||
|
||
/** 组节点:先挂用户叶子再递归子组;isParent=UserBase 已宣告子组,防"空组"被当作用户叶子 */
|
||
private SelectUserNodeVo groupNode(BoaGroup group) {
|
||
SelectUserNodeVo vo = new SelectUserNodeVo();
|
||
vo.setId(group.getId());
|
||
vo.setName(group.getName());
|
||
boolean declaredSub = group.getChildren() != null && !group.getChildren().isEmpty();
|
||
try {
|
||
List<BoaUser> members = groupClient.getGroupUsers(group.getId(), false, null);
|
||
if (members != null) {
|
||
for (BoaUser u : members) {
|
||
if (u != null && u.getId() != null) {
|
||
SelectUserNodeVo leaf = new SelectUserNodeVo();
|
||
leaf.setId(u.getId());
|
||
leaf.setName(u.getName() == null ? "" : u.getName());
|
||
vo.getChildren().add(leaf);
|
||
}
|
||
}
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("[flow-center] 组成员拉取失败(groupId={}, name={}):{}", group.getId(), group.getName(), e.getMessage());
|
||
}
|
||
for (BoaGroup child : group.getChildren() == null ? Collections.<BoaGroup>emptyList() : group.getChildren()) {
|
||
if (child != null && child.getId() != null) {
|
||
vo.getChildren().add(groupNode(child));
|
||
}
|
||
}
|
||
vo.setIsParent(declaredSub || !vo.getChildren().isEmpty());
|
||
return vo;
|
||
}
|
||
|
||
/**
|
||
* 选人组件树·教师版(2026-09-17 仿 Notice GetSchoolAll 口径):组树只做骨架(剔除学生组),
|
||
* 成员来自 userclass(YschClassOpenApi.getSchTeaList)的教师列表,按教师所属 GroupIds 挂组。
|
||
* 组内 getGroupUsers 不再混入——旧实现会把管理员等非教师账号挂进树(选人数据不对的根因)。
|
||
* 空组(无教师)剪枝;教师归属组全被剔除时挂学校根节点。
|
||
*/
|
||
public List<SelectUserNodeVo> teacherTree(Long schId) {
|
||
BoaGroup subtree;
|
||
try {
|
||
subtree = groupClient.getGroupById(schId.intValue(), true);
|
||
} catch (Exception e) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
|
||
}
|
||
if (subtree == null || subtree.getId() == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
|
||
}
|
||
// 扁平化组(剔除学生组整棵),组节点先建壳 isParent=true
|
||
Map<Integer, SelectUserNodeVo> groups = new LinkedHashMap<>();
|
||
List<BoaGroup> flat = new ArrayList<>();
|
||
flattenGroups(subtree, flat);
|
||
for (BoaGroup g : flat) {
|
||
SelectUserNodeVo vo = new SelectUserNodeVo();
|
||
vo.setId(g.getId());
|
||
vo.setName(g.getName() == null ? "" : g.getName());
|
||
vo.setIsParent(true);
|
||
groups.put(g.getId(), vo);
|
||
}
|
||
// 教师挂组;同一 (教师, 组) 去重,归属组全不在树内时兜底挂根
|
||
try {
|
||
List<com.yunhai.platform.openapi.clazz.model.SchTea> teachers =
|
||
com.yunhai.platform.openapi.clazz.YschClassOpenApi.getSchTeaList(schId.intValue());
|
||
if (teachers != null) {
|
||
for (com.yunhai.platform.openapi.clazz.model.SchTea tea : teachers) {
|
||
if (tea == null || tea.getUserTeaSerId() == null) {
|
||
continue;
|
||
}
|
||
SelectUserNodeVo leaf = new SelectUserNodeVo();
|
||
leaf.setId(tea.getUserTeaSerId());
|
||
leaf.setName(tea.getTeaName() == null ? "" : tea.getTeaName());
|
||
List<Integer> gids = tea.getGroupIds();
|
||
if ((gids == null || gids.isEmpty()) && tea.getGroupId() != null) {
|
||
gids = Collections.singletonList(tea.getGroupId());
|
||
}
|
||
boolean attached = false;
|
||
if (gids != null) {
|
||
for (Integer gid : gids) {
|
||
SelectUserNodeVo g = groups.get(gid);
|
||
if (g != null) {
|
||
g.getChildren().add(leaf);
|
||
attached = true;
|
||
}
|
||
}
|
||
}
|
||
if (!attached) {
|
||
SelectUserNodeVo root = groups.get(schId.intValue());
|
||
if (root != null) {
|
||
root.getChildren().add(leaf);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("[flow-center] 教师列表拉取失败(schId={}):{}", schId, e.getMessage());
|
||
}
|
||
// 建父子结构并剪空组(无教师后代的组不进树,根始终保留)
|
||
for (BoaGroup g : flat) {
|
||
for (BoaGroup child : g.getChildren() == null ? Collections.<BoaGroup>emptyList() : g.getChildren()) {
|
||
SelectUserNodeVo childVo = child == null ? null : groups.get(child.getId());
|
||
if (childVo != null && groups.containsKey(g.getId())) {
|
||
groups.get(g.getId()).getChildren().add(childVo);
|
||
}
|
||
}
|
||
}
|
||
SelectUserNodeVo root = groups.get(schId.intValue());
|
||
if (root == null) {
|
||
return new ArrayList<>();
|
||
}
|
||
pruneEmpty(root);
|
||
return Collections.singletonList(root);
|
||
}
|
||
|
||
/** 组子树扁平化;学生组(type=8)整棵剔除(含其后代) */
|
||
private void flattenGroups(BoaGroup group, List<BoaGroup> out) {
|
||
if (group == null || group.getId() == null) {
|
||
return;
|
||
}
|
||
if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
|
||
return;
|
||
}
|
||
out.add(group);
|
||
for (BoaGroup child : group.getChildren() == null ? Collections.<BoaGroup>emptyList() : group.getChildren()) {
|
||
flattenGroups(child, out);
|
||
}
|
||
}
|
||
|
||
/** 剪掉无教师叶子后代(isParent 组但 children 全空)的组节点;返回该子树是否仍有内容 */
|
||
private boolean pruneEmpty(SelectUserNodeVo node) {
|
||
List<SelectUserNodeVo> kept = new ArrayList<>();
|
||
for (SelectUserNodeVo child : node.getChildren() == null ? new ArrayList<SelectUserNodeVo>() : node.getChildren()) {
|
||
if (child.getIsParent() != null && child.getIsParent()) {
|
||
if (pruneEmpty(child)) {
|
||
kept.add(child);
|
||
}
|
||
} else {
|
||
kept.add(child);
|
||
}
|
||
}
|
||
node.getChildren().clear();
|
||
node.getChildren().addAll(kept);
|
||
return Boolean.TRUE.equals(node.getIsParent()) && !node.getChildren().isEmpty();
|
||
}
|
||
|
||
// ==================== 一键拉取教师组 ====================
|
||
|
||
/** 科室预览/导入项(deptKey=UserBase 组 id) */
|
||
@Getter
|
||
@Setter
|
||
public static class GroupDeptVo {
|
||
private String deptKey;
|
||
private String deptName;
|
||
private List<MemberVo> users = new ArrayList<>();
|
||
}
|
||
|
||
@Getter
|
||
@Setter
|
||
public static class MemberVo {
|
||
private Integer userId;
|
||
private String userName;
|
||
|
||
public MemberVo() {
|
||
}
|
||
|
||
public MemberVo(Integer userId, String userName) {
|
||
this.userId = userId;
|
||
this.userName = userName;
|
||
}
|
||
}
|
||
|
||
/** 导入结果计数 */
|
||
@Getter
|
||
@Setter
|
||
public static class GroupImportResultVo {
|
||
private int depts;
|
||
private int users;
|
||
private int skippedExisting;
|
||
}
|
||
|
||
/**
|
||
* 预览:学校子树内教师组容器 → 科室列表(含组内成员)。
|
||
* UserBase 不可达/学校组不存在时抛业务异常(预览即校验,导入前先给前端看结果)。
|
||
*/
|
||
public List<GroupDeptVo> previewGroupDepts(Long schId) {
|
||
BoaGroup subtree;
|
||
try {
|
||
subtree = groupClient.getGroupById(schId.intValue(), true);
|
||
} catch (Exception e) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
|
||
}
|
||
if (subtree == null || subtree.getId() == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
|
||
}
|
||
List<BoaGroup> depts = new ArrayList<>();
|
||
collectDeptGroups(subtree, depts);
|
||
List<GroupDeptVo> out = new ArrayList<>();
|
||
for (BoaGroup dept : depts) {
|
||
GroupDeptVo vo = new GroupDeptVo();
|
||
vo.setDeptKey(String.valueOf(dept.getId()));
|
||
vo.setDeptName(dept.getName());
|
||
try {
|
||
List<BoaUser> members = groupClient.getGroupUsers(dept.getId(), false, null);
|
||
if (members != null) {
|
||
for (BoaUser u : members) {
|
||
if (u != null && u.getId() != null) {
|
||
vo.getUsers().add(new MemberVo(u.getId(),
|
||
u.getName() == null ? "" : u.getName()));
|
||
}
|
||
}
|
||
}
|
||
} catch (Exception e) {
|
||
log.warn("[flow-center] 组成员拉取失败(groupId={}, name={}):{}", dept.getId(), dept.getName(), e.getMessage());
|
||
}
|
||
out.add(vo);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 一键导入:按 {@link #previewGroupDepts} 的口径批量落映射;已存在(sch+dept+user 且启用中)
|
||
* 跳过不重复刷写。空成员的科室保留为无映射科室(流程编辑器可见提示,成员后续手工补)。
|
||
*/
|
||
@Transactional
|
||
public GroupImportResultVo importGroups(Long schId) {
|
||
List<GroupDeptVo> depts = previewGroupDepts(schId);
|
||
GroupImportResultVo result = new GroupImportResultVo();
|
||
for (GroupDeptVo dept : depts) {
|
||
for (MemberVo m : dept.getUsers()) {
|
||
FlwAssigneeMap existing = mapRepository.findBySchIdAndDeptKeyAndUserId(
|
||
schId, dept.getDeptKey(), m.getUserId());
|
||
if (existing != null && existing.getStatus() == 0) {
|
||
result.setSkippedExisting(result.getSkippedExisting() + 1);
|
||
continue;
|
||
}
|
||
save(schId, dept.getDeptKey(), dept.getDeptName(), m.getUserId(), m.getUserName());
|
||
result.setUsers(result.getUsers() + 1);
|
||
}
|
||
result.setDepts(result.getDepts() + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 收集科室组:子树内 type=容器组类型的节点,其**子组**为科室(容器无子组时容器自身即科室)。
|
||
* 语义与 svc-hall ServiceItemService.buildDeptTree 逐字对齐(同一份学校组树两种消费)。
|
||
*/
|
||
private void collectDeptGroups(BoaGroup node, List<BoaGroup> out) {
|
||
if (node == null) {
|
||
return;
|
||
}
|
||
if (node.getType() != null && node.getType() == deptGroupType) {
|
||
if (node.getChildren() == null || node.getChildren().isEmpty()) {
|
||
out.add(node);
|
||
} else {
|
||
for (BoaGroup child : node.getChildren()) {
|
||
if (child != null && child.getId() != null) {
|
||
out.add(child);
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (node.getChildren() != null) {
|
||
for (BoaGroup child : node.getChildren()) {
|
||
collectDeptGroups(child, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
private String fetchName(Integer userId) {
|
||
try {
|
||
BoaUser user = userClient.getUserById(String.valueOf(userId));
|
||
return user == null ? "" : (user.getName() == null ? "" : user.getName());
|
||
} catch (Exception e) {
|
||
return "";
|
||
}
|
||
}
|
||
}
|