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.NotFoundException; import com.kaidi.oa.domain.DeclAcceptance; import com.kaidi.oa.repository.DeclAcceptanceRepository; import com.kaidi.oa.repository.DeclarationRepository; 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.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 申报项目验收任务(创新研发中心·申报服务部,需求 §3 验收管理)。 * * 区别于工程监理部竣工验收(CompletionAcceptance 绑 SupervisionProject),本控制器面向「科技计划/技改」等 * 政府申报项目的结题验收,补 PARTIAL(med):自动生成验收任务 + 验收材料编制审核状态机。 * * 日期 String(YYYY-MM-DD);状态迁移/批量改写带 {@link Transactional}。 */ @RestController @RequestMapping("/api/oa/decl-acceptances") public class DeclAcceptanceController { private static final String ST_PREP = "待准备"; private static final String ST_DRAFTING = "材料编制中"; private static final String ST_TOREVIEW = "待审核"; private static final String ST_REVIEWED = "已审核"; private static final String ST_ACCEPTED = "已验收"; private final DeclAcceptanceRepository repo; private final DeclarationRepository declRepo; private final CurrentUserResolver currentUser; private final ObjectMapper objectMapper; public DeclAcceptanceController(DeclAcceptanceRepository repo, DeclarationRepository declRepo, CurrentUserResolver currentUser, ObjectMapper objectMapper) { this.repo = repo; this.declRepo = declRepo; this.currentUser = currentUser; this.objectMapper = objectMapper; } @GetMapping public ApiResp> list(@RequestParam(required = false) String status, @RequestParam(required = false) Long declarationId) { if (status != null && !status.isBlank()) { return ApiResp.ok(repo.findByStatus(status)); } if (declarationId != null) { return ApiResp.ok(repo.findByDeclarationId(declarationId)); } return ApiResp.ok(repo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(load(id)); } public record CreateRequest(Long declarationId, String projectName, String authority, String acceptType, String planDate, String owner, String remark) { } /** 自动生成验收任务:按 acceptType 内置标准验收材料清单(系统自动生成),初始「待准备」。 */ @PostMapping @Transactional public ApiResp 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()); } DeclAcceptance a = new DeclAcceptance(); a.setCode("YS-" + (repo.count() + 1)); a.setDeclarationId(req.declarationId()); a.setProjectName(req.projectName().trim()); a.setAuthority(req.authority()); String type = req.acceptType() == null || req.acceptType().isBlank() ? "结题验收" : req.acceptType(); a.setAcceptType(type); a.setStatus(ST_PREP); a.setMaterialsJson(writeMaterials(defaultMaterials(type))); a.setPlanDate(req.planDate()); a.setOwner(req.owner() == null || req.owner().isBlank() ? currentUser.resolveLabel(request) : req.owner()); a.setRemark(req.remark()); a.setCreatedAt(Instant.now()); return ApiResp.ok(repo.save(a)); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody CreateRequest req) { DeclAcceptance a = load(id); if (req.projectName() != null && !req.projectName().isBlank()) a.setProjectName(req.projectName().trim()); if (req.authority() != null) a.setAuthority(req.authority()); if (req.acceptType() != null && !req.acceptType().isBlank()) a.setAcceptType(req.acceptType()); if (req.planDate() != null) a.setPlanDate(req.planDate()); if (req.owner() != null) a.setOwner(req.owner()); if (req.remark() != null) a.setRemark(req.remark()); // status / materialsJson / acceptedDate 仅由业务动作推进,禁止客户端直写。 return ApiResp.ok(repo.save(a)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!repo.existsById(id)) { throw new NotFoundException("decl acceptance not found: " + id); } repo.deleteById(id); return ApiResp.ok(null); } // ---------- 材料编制 ---------- public record MaterialFlag(String name, Boolean ready) { } public record MaterialsRequest(List materials) { } /** 勾选/更新验收材料齐套状态。首次编制把「待准备」推进到「材料编制中」。 */ @PostMapping("/{id}/materials") @Transactional public ApiResp materials(@PathVariable Long id, @RequestBody MaterialsRequest req) { DeclAcceptance a = load(id); if (ST_ACCEPTED.equals(a.getStatus())) { throw new ApiException(409, "已验收,材料不可再改"); } List> mats = readMaterials(a.getMaterialsJson()); if (req != null && req.materials() != null) { for (MaterialFlag f : req.materials()) { for (Map m : mats) { if (f.name() != null && f.name().equals(m.get("name"))) { m.put("ready", Boolean.TRUE.equals(f.ready())); } } } } a.setMaterialsJson(writeMaterials(mats)); if (ST_PREP.equals(a.getStatus())) { a.setStatus(ST_DRAFTING); } return ApiResp.ok(repo.save(a)); } /** 提交审核:要求所有材料齐套后方可提交。置「待审核」。 */ @PostMapping("/{id}/submit-review") @Transactional public ApiResp submitReview(@PathVariable Long id) { DeclAcceptance a = load(id); if (!ST_DRAFTING.equals(a.getStatus()) && !ST_PREP.equals(a.getStatus())) { throw new ApiException(400, "当前状态「" + a.getStatus() + "」不可提交审核"); } List> mats = readMaterials(a.getMaterialsJson()); long notReady = mats.stream().filter(m -> !Boolean.TRUE.equals(m.get("ready"))).count(); if (notReady > 0) { throw new ApiException(400, "尚有 " + notReady + " 项验收材料未齐套,不能提交审核"); } a.setStatus(ST_TOREVIEW); return ApiResp.ok(repo.save(a)); } public record ReviewRequest(boolean pass, String reviewer, String note) { } /** 材料审核:通过进「已审核」,驳回退回「材料编制中」。审核人/意见留存。 */ @PostMapping("/{id}/review") @Transactional public ApiResp review(@PathVariable Long id, @RequestBody ReviewRequest req, HttpServletRequest request) { DeclAcceptance a = load(id); if (!ST_TOREVIEW.equals(a.getStatus())) { throw new ApiException(400, "当前状态「" + a.getStatus() + "」无待审核的验收材料"); } a.setReviewer(req.reviewer() == null || req.reviewer().isBlank() ? currentUser.resolveLabel(request) : req.reviewer()); a.setReviewNote(req.note()); a.setStatus(req.pass() ? ST_REVIEWED : ST_DRAFTING); return ApiResp.ok(repo.save(a)); } public record AcceptRequest(String acceptedDate) { } /** 完成验收:要求已审核,置「已验收」并回写实际验收日。 */ @PostMapping("/{id}/accept") @Transactional public ApiResp accept(@PathVariable Long id, @RequestBody(required = false) AcceptRequest req) { DeclAcceptance a = load(id); if (!ST_REVIEWED.equals(a.getStatus())) { throw new ApiException(400, "需材料审核通过(已审核)后方可完成验收"); } a.setStatus(ST_ACCEPTED); a.setAcceptedDate(req != null && req.acceptedDate() != null && !req.acceptedDate().isBlank() ? req.acceptedDate() : LocalDate.now().toString()); return ApiResp.ok(repo.save(a)); } // ---------- 到期分级预警 ---------- public record DueAlert(Long id, String code, String projectName, String acceptType, String planDate, String status, long daysToDue, String level) { } @GetMapping("/alerts/due") public ApiResp> dueAlerts(@RequestParam(required = false, defaultValue = "60") int days) { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (DeclAcceptance a : repo.findAll()) { if (ST_ACCEPTED.equals(a.getStatus())) { continue; } LocalDate due = parseDateOrNull(a.getPlanDate()); if (due == null) { continue; } long daysTo = ChronoUnit.DAYS.between(today, due); if (daysTo > days) { continue; } String level = daysTo < 0 ? "逾期" : daysTo <= 7 ? "紧急" : daysTo <= 15 ? "临近" : "关注"; out.add(new DueAlert(a.getId(), a.getCode(), a.getProjectName(), a.getAcceptType(), a.getPlanDate(), a.getStatus(), daysTo, level)); } out.sort((x, y) -> Long.compare(x.daysToDue(), y.daysToDue())); return ApiResp.ok(out); } // ---------- helpers ---------- private DeclAcceptance load(Long id) { return repo.findById(id) .orElseThrow(() -> new NotFoundException("decl acceptance not found: " + id)); } /** 按验收类型生成标准材料清单。 */ private static List> defaultMaterials(String type) { List names; if ("技改验收".equals(type)) { names = List.of("项目总结报告", "财务决算报告", "设备检测报告", "环保验收意见", "用户使用证明"); } else if ("中期检查".equals(type)) { names = List.of("中期进展报告", "经费使用情况表", "阶段成果清单"); } else if ("绩效评价".equals(type)) { names = List.of("绩效自评报告", "资金到账与支出明细", "成果转化证明", "社会效益说明"); } else { // 结题验收(默认) names = List.of("项目总结报告", "财务决算报告", "检测/测试报告", "成果证明材料", "用户意见"); } List> mats = new ArrayList<>(); for (String n : names) { Map m = new LinkedHashMap<>(); m.put("name", n); m.put("ready", Boolean.FALSE); mats.add(m); } return mats; } private List> readMaterials(String json) { if (json == null || json.isBlank()) { return new ArrayList<>(); } try { List> parsed = objectMapper.readValue( json, new TypeReference>>() { }); return parsed == null ? new ArrayList<>() : new ArrayList<>(parsed); } catch (Exception e) { return new ArrayList<>(); } } private String writeMaterials(List> mats) { try { return objectMapper.writeValueAsString(mats); } catch (Exception e) { return "[]"; } } private static LocalDate parseDateOrNull(String s) { if (s == null || s.isBlank()) { return null; } try { String t = s.trim(); return LocalDate.parse(t.substring(0, Math.min(10, t.length()))); } catch (DateTimeParseException | IndexOutOfBoundsException e) { return null; } } }