恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
10 KiB
Java
206 lines
10 KiB
Java
package com.kaidi.oa.web;
|
|
|
|
import com.kaidi.oa.common.ApiException;
|
|
import com.kaidi.oa.common.ApiResp;
|
|
import com.kaidi.oa.common.NotFoundException;
|
|
import com.kaidi.oa.domain.FlowTrace;
|
|
import com.kaidi.oa.domain.FormInstance;
|
|
import com.kaidi.oa.domain.Delegation;
|
|
import com.kaidi.oa.repository.FlowTraceRepository;
|
|
import com.kaidi.oa.repository.FormInstanceRepository;
|
|
import com.kaidi.oa.service.NodeAssigneeResolver;
|
|
import com.kaidi.oa.service.NotificationService;
|
|
import com.kaidi.oa.service.WorkflowService;
|
|
import jakarta.servlet.http.HttpServletRequest;
|
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
import org.springframework.web.bind.annotation.PathVariable;
|
|
import org.springframework.web.bind.annotation.PostMapping;
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|
import org.springframework.web.bind.annotation.RequestParam;
|
|
import org.springframework.web.bind.annotation.RestController;
|
|
|
|
import java.time.Instant;
|
|
import java.util.HashSet;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
|
|
/**
|
|
* Per-user task views over form instances, mirroring the frontend four-list model.
|
|
* Identity is taken from the auth token (server-authoritative), NOT from a query param.
|
|
* - todo: in-flight items whose CURRENT node resolves to me (NodeAssigneeResolver)
|
|
* - done: 已办结 items I originated or acted on (appear in the flow trace)
|
|
* - sent: non-draft items I originated
|
|
* - draft: 草稿 items I originated
|
|
*/
|
|
@RestController
|
|
@RequestMapping("/api/oa/tasks")
|
|
public class TaskController {
|
|
|
|
private final FormInstanceRepository instanceRepo;
|
|
private final FlowTraceRepository traceRepo;
|
|
private final CurrentUserResolver currentUser;
|
|
private final NodeAssigneeResolver assigneeResolver;
|
|
private final NotificationService notifications;
|
|
private final WorkflowService workflowService;
|
|
private final com.kaidi.oa.service.AuthorizationService authz;
|
|
|
|
public TaskController(FormInstanceRepository instanceRepo,
|
|
FlowTraceRepository traceRepo,
|
|
CurrentUserResolver currentUser,
|
|
NodeAssigneeResolver assigneeResolver,
|
|
NotificationService notifications,
|
|
WorkflowService workflowService,
|
|
com.kaidi.oa.service.AuthorizationService authz) {
|
|
this.instanceRepo = instanceRepo;
|
|
this.traceRepo = traceRepo;
|
|
this.currentUser = currentUser;
|
|
this.assigneeResolver = assigneeResolver;
|
|
this.notifications = notifications;
|
|
this.workflowService = workflowService;
|
|
this.authz = authz;
|
|
}
|
|
|
|
@GetMapping
|
|
public ApiResp<List<Map<String, Object>>> list(
|
|
@RequestParam(defaultValue = "todo") String type,
|
|
HttpServletRequest request) {
|
|
|
|
// Identity is taken from the auth token, not the (now ignored) ?user= param.
|
|
String me = currentUser.resolveLabel(request);
|
|
List<FormInstance> all = instanceRepo.findAll();
|
|
// Principals who have delegated their 待办 to me (active, in-window). When the node
|
|
// owner is one of these, the item also surfaces in my box (委托代理).
|
|
Set<String> myPrincipals = principalsDelegatingTo(me);
|
|
|
|
List<FormInstance> filtered = switch (type) {
|
|
case "todo" -> all.stream()
|
|
.filter(it -> "待办".equals(it.getStatus())
|
|
|| "办理中".equals(it.getStatus())
|
|
|| "已退回".equals(it.getStatus()))
|
|
// A 转交/加签 改派 sets assigneeOverride: that person becomes the sole todo owner
|
|
// (so the item moves into their box and out of the original heuristic owner's).
|
|
// Otherwise fall back to the heuristic node→user resolver; a delegate also
|
|
// sees their principal's items.
|
|
.filter(it -> {
|
|
String override = it.getAssigneeOverride();
|
|
if (override != null && !override.isBlank()) {
|
|
return me.equals(override) || myPrincipals.contains(override);
|
|
}
|
|
// 真并行审批同步节点 (currentNodeId 以「并行会签@」开头): the synthetic 会签 label
|
|
// resolves to a real approver, not the submitter, so the heuristic owner alone
|
|
// would hide the item from the submitter's 待办 box (UI 无法办理). Treat the
|
|
// submitter AND every distinct group approver as owners; the current user is in
|
|
// the box if they (or a principal they represent) match any of them.
|
|
if (workflowService.isParallelSyncNodeId(it.getCurrentNodeId())) {
|
|
Set<String> owners = workflowService.parallelSyncTodoOwners(it);
|
|
for (String owner : owners) {
|
|
if (me.equals(owner) || myPrincipals.contains(owner)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
String owner = assigneeResolver.resolve(it.getCurrentNode(), it.getOriginUser());
|
|
return me.equals(owner) || myPrincipals.contains(owner);
|
|
})
|
|
.toList();
|
|
case "done" -> {
|
|
Set<Long> involved = instanceIdsIActedOn(me);
|
|
yield all.stream()
|
|
.filter(it -> "已办结".equals(it.getStatus()))
|
|
.filter(it -> me.equals(it.getOriginUser()) || involved.contains(it.getId()))
|
|
.toList();
|
|
}
|
|
case "sent" -> all.stream()
|
|
.filter(it -> !"草稿".equals(it.getStatus()))
|
|
.filter(it -> me.equals(it.getOriginUser()))
|
|
.toList();
|
|
case "draft" -> all.stream()
|
|
.filter(it -> "草稿".equals(it.getStatus()))
|
|
.filter(it -> me.equals(it.getOriginUser()))
|
|
.toList();
|
|
default -> throw new ApiException(400, "unknown task type: " + type);
|
|
};
|
|
return ApiResp.ok(filtered.stream().map(TaskController::toView).toList());
|
|
}
|
|
|
|
/**
|
|
* 督办/催办 (M1): bump the instance's urgeCount + lastUrgedAt and push a 督办 notice to
|
|
* the current node's handler. Persists so a page refresh keeps the 已督办 mark. No-op-safe
|
|
* on 已办结 items (returns the current counters without notifying).
|
|
*/
|
|
@PostMapping("/{instanceId}/urge")
|
|
public ApiResp<Map<String, Object>> urge(@PathVariable Long instanceId, HttpServletRequest request) {
|
|
FormInstance inst = instanceRepo.findById(instanceId)
|
|
.orElseThrow(() -> new NotFoundException("instance not found: " + instanceId));
|
|
// 对象级:催办会向当前办理人推送通知,仅发起人本人(按稳定 originUserId)或 ADMIN 可催办自己的单据,
|
|
// 杜绝任意登录用户枚举 instanceId 对他人单据的办理人刷督办通知(BFLA/通知骚扰)。
|
|
com.kaidi.oa.domain.SysUser su = currentUser.resolve(request);
|
|
if (!authz.isAdmin(su) && !authz.isOwner(su, inst.getOriginUserId(), inst.getOriginUser())) {
|
|
throw new com.kaidi.oa.common.ApiException(403, "无权督办:仅发起人本人或管理员可催办该事项");
|
|
}
|
|
String me = currentUser.resolveLabel(request);
|
|
|
|
if (!"已办结".equals(inst.getStatus())) {
|
|
int next = (inst.getUrgeCount() == null ? 0 : inst.getUrgeCount()) + 1;
|
|
inst.setUrgeCount(next);
|
|
inst.setLastUrgedAt(Instant.now());
|
|
inst.setUpdatedAt(Instant.now());
|
|
instanceRepo.save(inst);
|
|
|
|
String handler = inst.getAssigneeOverride() != null && !inst.getAssigneeOverride().isBlank()
|
|
? inst.getAssigneeOverride()
|
|
: assigneeResolver.resolve(inst.getCurrentNode(), inst.getOriginUser());
|
|
notifications.notify(handler, "督办",
|
|
"督办提醒:" + inst.getTitle(),
|
|
"事项「" + inst.getTitle() + "」在节点「" + inst.getCurrentNode()
|
|
+ "」被 " + me + " 督办(第 " + next + " 次),请尽快办理。",
|
|
"instance", inst.getId());
|
|
}
|
|
|
|
Map<String, Object> out = new LinkedHashMap<>();
|
|
out.put("id", inst.getId());
|
|
out.put("urgeCount", inst.getUrgeCount() == null ? 0 : inst.getUrgeCount());
|
|
out.put("lastUrgedAt", inst.getLastUrgedAt());
|
|
return ApiResp.ok(out);
|
|
}
|
|
|
|
/** Principals who have an active, in-window delegation to me (so I see their 待办). */
|
|
private Set<String> principalsDelegatingTo(String me) {
|
|
return workflowService.principalsDelegatingTo(me);
|
|
}
|
|
|
|
/** Instance ids where the given user appears as an actor in the flow trace. */
|
|
private Set<Long> instanceIdsIActedOn(String me) {
|
|
Set<Long> ids = new HashSet<>();
|
|
for (FlowTrace t : traceRepo.findAll()) {
|
|
if (me.equals(t.getWho())) {
|
|
ids.add(t.getInstanceId());
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
private static Map<String, Object> toView(FormInstance inst) {
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
m.put("id", inst.getId());
|
|
m.put("templateId", inst.getTemplateId());
|
|
m.put("templateName", inst.getTemplateName());
|
|
m.put("category", inst.getCategory());
|
|
m.put("title", inst.getTitle());
|
|
m.put("status", inst.getStatus());
|
|
m.put("currentNode", inst.getCurrentNode());
|
|
m.put("currentNodeId", inst.getCurrentNodeId());
|
|
m.put("nodeIndex", inst.getNodeIndex());
|
|
m.put("originUser", inst.getOriginUser());
|
|
m.put("assigneeOverride", inst.getAssigneeOverride());
|
|
m.put("urgeCount", inst.getUrgeCount() == null ? 0 : inst.getUrgeCount());
|
|
m.put("lastUrgedAt", inst.getLastUrgedAt());
|
|
m.put("createdAt", inst.getCreatedAt());
|
|
m.put("updatedAt", inst.getUpdatedAt());
|
|
return m;
|
|
}
|
|
}
|