SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)

恢复点(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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,205 @@
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;
}
}