Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/DeclSubmissionController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

353 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.kaidi.oa.web;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.DeclCrypto;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.DeclSubmission;
import com.kaidi.oa.domain.SysUser;
import com.kaidi.oa.repository.DeclSubmissionRepository;
import com.kaidi.oa.repository.DeclarationRepository;
import com.kaidi.oa.service.AuthorizationService;
import com.kaidi.oa.service.NotificationService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 申报外部提交工作流(创新研发中心·申报服务部,需求 §2 内部审核流程 + 外部申报提交)。
*
* 补齐审计点名的多处缺口(PARTIAL · high):
* <ul>
* <li>真会签链状态机(非单纯线性):草稿 → 申报专员 → 部门负责人 → 技术会签 → 财务会签 → 法务会签
* → 高管核准 → 已提交;每一步 /{id}/approve 通过、/{id}/reject 驳回回草稿,意见全程留痕;</li>
* <li>外部账号密码加密存储:portalPassword 落库前经 {@link DeclCrypto} 可逆加密,列表/详情仅回掩码,
* 仅 /{id}/reveal-credentialADMIN/APPROVER)才解密返回明文,杜绝明文入库/越权读取;</li>
* <li>电子签章 + 外部提交:/{id}/esign 标记已签章,/{id}/submit 在「高管核准且已签章」后置「已提交」、
* 回填政务平台受理回执 receiptNo 与提交日,并通知发起人。</li>
* </ul>
*
* 受 AuthInterceptor 默认保护;含凭据,归入敏感读写门禁(见 sharedFileSnippets)。
*/
@RestController
@RequestMapping("/api/oa/decl-submissions")
public class DeclSubmissionController {
/** 会签链(草稿为起点,已提交为终点)。 */
static final List<String> CHAIN = List.of(
"草稿", "申报专员", "部门负责人", "技术会签", "财务会签", "法务会签", "高管核准", "已提交");
private static final String ST_DRAFT = "草稿";
private static final String ST_APPROVED = "高管核准";
private static final String ST_SUBMITTED = "已提交";
private static final String ESIGN_SIGNED = "已签章";
private static final Set<String> REVEAL_ROLES = Set.of("ADMIN", "APPROVER");
private final DeclSubmissionRepository repo;
private final DeclarationRepository declRepo;
private final CurrentUserResolver currentUser;
private final AuthorizationService authz;
private final NotificationService notifications;
private final ObjectMapper objectMapper;
public DeclSubmissionController(DeclSubmissionRepository repo,
DeclarationRepository declRepo,
CurrentUserResolver currentUser,
AuthorizationService authz,
NotificationService notifications,
ObjectMapper objectMapper) {
this.repo = repo;
this.declRepo = declRepo;
this.currentUser = currentUser;
this.authz = authz;
this.notifications = notifications;
this.objectMapper = objectMapper;
}
// ---------- 列表/详情(口令永不出现在响应中) ----------
public record SubmissionView(Long id, String code, Long declarationId, String projectName,
String declType, String reviewStage, String govPlatform,
String portalAccount, String passwordMask, String esignStatus,
String receiptNo, String submittedDate, String owner,
List<Map<String, Object>> reviewTrail, String remark) {
}
private SubmissionView toView(DeclSubmission s) {
return new SubmissionView(s.getId(), s.getCode(), s.getDeclarationId(), s.getProjectName(),
s.getDeclType(), s.getReviewStage(), s.getGovPlatform(), s.getPortalAccount(),
DeclCrypto.mask(s.getPortalPasswordEnc()), s.getEsignStatus(), s.getReceiptNo(),
s.getSubmittedDate(), s.getOwner(), readTrail(s.getReviewTrailJson()), s.getRemark());
}
@GetMapping
public ApiResp<List<SubmissionView>> list(@RequestParam(required = false) String stage,
@RequestParam(required = false) Long declarationId) {
List<DeclSubmission> rows;
if (stage != null && !stage.isBlank()) {
rows = repo.findByReviewStage(stage);
} else if (declarationId != null) {
rows = repo.findByDeclarationId(declarationId);
} else {
rows = repo.findAll();
}
List<SubmissionView> out = new ArrayList<>();
for (DeclSubmission s : rows) {
out.add(toView(s));
}
return ApiResp.ok(out);
}
@GetMapping("/{id}")
public ApiResp<SubmissionView> get(@PathVariable Long id) {
return ApiResp.ok(toView(load(id)));
}
public record CreateRequest(Long declarationId, String projectName, String declType,
String govPlatform, String portalAccount, String portalPassword,
String owner, String remark) {
}
@PostMapping
@Transactional
public ApiResp<SubmissionView> create(@RequestBody CreateRequest req, HttpServletRequest request) {
if (req.projectName() == null || req.projectName().isBlank()) {
throw new ApiException(400, "申报项目名称(projectName) 不能为空");
}
if (req.declarationId() != null && !declRepo.existsById(req.declarationId())) {
throw new ApiException(400, "关联的申报项目不存在:" + req.declarationId());
}
DeclSubmission s = new DeclSubmission();
s.setCode("TJ-" + (repo.count() + 1));
s.setDeclarationId(req.declarationId());
s.setProjectName(req.projectName().trim());
s.setDeclType(req.declType() == null || req.declType().isBlank() ? "首次" : req.declType());
s.setGovPlatform(req.govPlatform());
s.setPortalAccount(req.portalAccount());
// 口令加密落库(永不明文入库)。
s.setPortalPasswordEnc(DeclCrypto.encrypt(req.portalPassword()));
s.setEsignStatus("未签");
s.setReviewStage(ST_DRAFT);
s.setOwner(req.owner() == null || req.owner().isBlank() ? currentUser.resolveLabel(request) : req.owner());
s.setRemark(req.remark());
s.setReviewTrailJson(writeTrail(new ArrayList<>()));
s.setCreatedAt(Instant.now());
return ApiResp.ok(toView(repo.save(s)));
}
public record UpdateRequest(String govPlatform, String portalAccount, String portalPassword,
String declType, String owner, String remark) {
}
@PatchMapping("/{id}")
@Transactional
public ApiResp<SubmissionView> update(@PathVariable Long id, @RequestBody UpdateRequest req) {
DeclSubmission s = load(id);
if (req.govPlatform() != null) s.setGovPlatform(req.govPlatform());
if (req.portalAccount() != null) s.setPortalAccount(req.portalAccount());
// 仅当传入非空新口令才重新加密覆盖(空串=不改),避免误清空。
if (req.portalPassword() != null && !req.portalPassword().isBlank()) {
s.setPortalPasswordEnc(DeclCrypto.encrypt(req.portalPassword()));
}
if (req.declType() != null) s.setDeclType(req.declType());
if (req.owner() != null) s.setOwner(req.owner());
if (req.remark() != null) s.setRemark(req.remark());
// reviewStage / esignStatus / receiptNo 仅由业务动作推进,禁止客户端直写。
return ApiResp.ok(toView(repo.save(s)));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!repo.existsById(id)) {
throw new NotFoundException("decl submission not found: " + id);
}
repo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 会签链状态机 ----------
public record ApproveRequest(String note) {
}
/** 会签通过:沿 CHAIN 前进一步(到「高管核准」止,再要外部提交走 /submit)。每步留痕。 */
@PostMapping("/{id}/approve")
@Transactional
public ApiResp<SubmissionView> approve(@PathVariable Long id,
@RequestBody(required = false) ApproveRequest req,
HttpServletRequest request) {
DeclSubmission s = load(id);
int idx = CHAIN.indexOf(s.getReviewStage());
if (idx < 0) {
throw new ApiException(400, "未知会签阶段:" + s.getReviewStage());
}
if (ST_SUBMITTED.equals(s.getReviewStage())) {
throw new ApiException(409, "已外部提交,无需再会签");
}
if (CHAIN.get(idx).equals(ST_APPROVED)) {
throw new ApiException(400, "已至「高管核准」,请走外部提交(/submit)");
}
String next = CHAIN.get(idx + 1);
// 跳过「已提交」末节点:会签链只到高管核准为止。
if (ST_SUBMITTED.equals(next)) {
throw new ApiException(400, "会签已到「高管核准」,请走外部提交(/submit)");
}
s.setReviewStage(next);
appendTrail(s, next, "通过", note(req == null ? null : req.note(), "会签通过"),
currentUser.resolveLabel(request));
return ApiResp.ok(toView(repo.save(s)));
}
public record RejectRequest(String note) {
}
/** 会签驳回:任一会签节点驳回,整单退回「草稿」重新发起。留痕记录驳回意见。 */
@PostMapping("/{id}/reject")
@Transactional
public ApiResp<SubmissionView> reject(@PathVariable Long id,
@RequestBody(required = false) RejectRequest req,
HttpServletRequest request) {
DeclSubmission s = load(id);
if (ST_SUBMITTED.equals(s.getReviewStage())) {
throw new ApiException(409, "已外部提交,无法驳回");
}
if (ST_DRAFT.equals(s.getReviewStage())) {
throw new ApiException(400, "尚在草稿,无可驳回的会签");
}
String from = s.getReviewStage();
s.setReviewStage(ST_DRAFT);
String operator = currentUser.resolveLabel(request);
appendTrail(s, from, "驳回", note(req == null ? null : req.note(), "会签驳回,退回草稿"), operator);
notifications.notify(s.getOwner(), "申报",
"申报会签被驳回:" + s.getProjectName(),
"「" + s.getProjectName() + "」在「" + from + "」节点被 " + operator + " 驳回,请修订后重新发起会签。",
"decl_submission", s.getId());
return ApiResp.ok(toView(repo.save(s)));
}
/** 电子签章:在外部提交前对申报书电子签章。仅「高管核准」后允许签章。 */
@PostMapping("/{id}/esign")
@Transactional
public ApiResp<SubmissionView> esign(@PathVariable Long id) {
DeclSubmission s = load(id);
if (!ST_APPROVED.equals(s.getReviewStage())) {
throw new ApiException(400, "需会签至「高管核准」后方可电子签章(当前:" + s.getReviewStage() + "");
}
s.setEsignStatus(ESIGN_SIGNED);
return ApiResp.ok(toView(repo.save(s)));
}
public record SubmitRequest(String receiptNo, String submittedDate) {
}
/** 外部提交至政务平台:要求「高管核准 + 已签章」,回填受理回执与提交日,置「已提交」,通知发起人。 */
@PostMapping("/{id}/submit")
@Transactional
public ApiResp<SubmissionView> submit(@PathVariable Long id,
@RequestBody(required = false) SubmitRequest req,
HttpServletRequest request) {
DeclSubmission s = load(id);
if (ST_SUBMITTED.equals(s.getReviewStage())) {
throw new ApiException(409, "已提交,请勿重复");
}
if (!ST_APPROVED.equals(s.getReviewStage())) {
throw new ApiException(400, "需会签至「高管核准」后方可外部提交(当前:" + s.getReviewStage() + "");
}
if (!ESIGN_SIGNED.equals(s.getEsignStatus())) {
throw new ApiException(400, "外部提交前需先完成电子签章");
}
s.setReviewStage(ST_SUBMITTED);
s.setReceiptNo(req == null ? null : req.receiptNo());
s.setSubmittedDate(req != null && req.submittedDate() != null && !req.submittedDate().isBlank()
? req.submittedDate() : LocalDate.now().toString());
appendTrail(s, ST_SUBMITTED, "提交",
"已向「" + (s.getGovPlatform() == null ? "政务平台" : s.getGovPlatform())
+ "」提交,受理回执:" + (s.getReceiptNo() == null ? "(待回填)" : s.getReceiptNo()),
currentUser.resolveLabel(request));
notifications.notify(s.getOwner(), "申报",
"申报已外部提交:" + s.getProjectName(),
"「" + s.getProjectName() + "」已提交至政务平台,受理回执:" + s.getReceiptNo(),
"decl_submission", s.getId());
return ApiResp.ok(toView(repo.save(s)));
}
// ---------- 凭据解密(仅 ADMIN/APPROVER ----------
public record CredentialView(Long id, String govPlatform, String portalAccount, String portalPassword) {
}
/** 解密返回政务平台明文口令,供真实登录代填。仅 ADMIN/APPROVER 可读,否则 403。 */
@PostMapping("/{id}/reveal-credential")
public ApiResp<CredentialView> reveal(@PathVariable Long id, HttpServletRequest request) {
SysUser su = currentUser.resolve(request);
if (su == null || !authz.hasAnyRole(su.getId(), REVEAL_ROLES)) {
throw new ApiException(403, "无权查看政务平台登录口令(需 ADMIN/APPROVER");
}
DeclSubmission s = load(id);
return ApiResp.ok(new CredentialView(s.getId(), s.getGovPlatform(), s.getPortalAccount(),
DeclCrypto.decrypt(s.getPortalPasswordEnc())));
}
// ---------- helpers ----------
private DeclSubmission load(Long id) {
return repo.findById(id)
.orElseThrow(() -> new NotFoundException("decl submission not found: " + id));
}
private static String note(String raw, String fallback) {
return raw == null || raw.isBlank() ? fallback : raw;
}
private void appendTrail(DeclSubmission s, String stage, String decision, String note, String operator) {
List<Map<String, Object>> trail = readTrail(s.getReviewTrailJson());
Map<String, Object> e = new LinkedHashMap<>();
e.put("stage", stage);
e.put("decision", decision);
e.put("note", note);
e.put("operator", operator);
e.put("at", Instant.now().toString());
trail.add(e);
s.setReviewTrailJson(writeTrail(trail));
}
private List<Map<String, Object>> readTrail(String json) {
if (json == null || json.isBlank()) {
return new ArrayList<>();
}
try {
List<Map<String, Object>> parsed = objectMapper.readValue(
json, new TypeReference<List<Map<String, Object>>>() {
});
return parsed == null ? new ArrayList<>() : new ArrayList<>(parsed);
} catch (Exception e) {
return new ArrayList<>();
}
}
private String writeTrail(List<Map<String, Object>> trail) {
try {
return objectMapper.writeValueAsString(trail);
} catch (Exception e) {
return "[]";
}
}
}