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.BranchLicense; import com.kaidi.oa.repository.BranchLicenseRepository; 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.List; /** * 分公司证照专项台账(分公司模块·行政与资产管理 §6)。 * * 审计缺口补完: * ① 营业执照号、经营范围、注册资本、登记机关等专项字段; * ② 年检到期日 + 年检周期提醒(annualInspectDue,<=30天触发临检,已过期触发逾期); * ③ 有效期状态机(有效/临期/已过期),读时自动刷新; * ④ GET /alerts/annual-inspect 年检分级预警端点,驱动到期提醒。 * * 写口 default-deny(ADMIN/APPROVER)保护;含机密信息,读口属受限资源。 */ @RestController @RequestMapping("/api/oa/branch-licenses") public class BranchLicenseController { private final BranchLicenseRepository repo; public BranchLicenseController(BranchLicenseRepository repo) { this.repo = repo; } // ---------- CRUD ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) String org, @RequestParam(required = false) String licenseType, @RequestParam(required = false) String status) { List all; if (org != null && !org.isBlank()) { all = repo.findByOrg(org); } else if (licenseType != null && !licenseType.isBlank()) { all = repo.findByLicenseType(licenseType); } else { all = repo.findAll(); } for (BranchLicense lic : all) { refreshExpiryView(lic); } if (status != null && !status.isBlank()) { List filtered = new ArrayList<>(); for (BranchLicense lic : all) { if (status.equals(lic.getStatus())) { filtered.add(lic); } } return ApiResp.ok(filtered); } return ApiResp.ok(all); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { BranchLicense lic = repo.findById(id) .orElseThrow(() -> new NotFoundException("branch license not found: " + id)); refreshExpiryView(lic); return ApiResp.ok(lic); } public record LicenseRequest( String org, String licenseType, String name, String regNo, String bizScope, String registeredCapital, String regAuthority, String issueDate, String expiryDate, String annualInspectDue, String remark, String owner) { } @PostMapping public ApiResp create(@RequestBody LicenseRequest req) { if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "证照名称必填"); } BranchLicense lic = new BranchLicense(); lic.setOrg(req.org()); lic.setLicenseType(req.licenseType() == null || req.licenseType().isBlank() ? "营业执照" : req.licenseType()); lic.setName(req.name()); lic.setRegNo(req.regNo()); lic.setBizScope(req.bizScope()); lic.setRegisteredCapital(req.registeredCapital()); lic.setRegAuthority(req.regAuthority()); lic.setIssueDate(req.issueDate()); lic.setExpiryDate(req.expiryDate()); lic.setAnnualInspectDue(req.annualInspectDue()); lic.setRemark(req.remark()); lic.setOwner(req.owner()); lic.setCreatedAt(Instant.now()); applyExpiryState(lic); return ApiResp.ok(repo.save(lic)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody LicenseRequest req) { BranchLicense lic = repo.findById(id) .orElseThrow(() -> new NotFoundException("branch license not found: " + id)); if (req.org() != null) lic.setOrg(req.org()); if (req.licenseType() != null && !req.licenseType().isBlank()) lic.setLicenseType(req.licenseType()); if (req.name() != null) { if (req.name().isBlank()) throw new ApiException(400, "证照名称不能为空"); lic.setName(req.name()); } if (req.regNo() != null) lic.setRegNo(req.regNo()); if (req.bizScope() != null) lic.setBizScope(req.bizScope()); if (req.registeredCapital() != null) lic.setRegisteredCapital(req.registeredCapital()); if (req.regAuthority() != null) lic.setRegAuthority(req.regAuthority()); if (req.issueDate() != null) lic.setIssueDate(req.issueDate()); if (req.expiryDate() != null) lic.setExpiryDate(req.expiryDate()); if (req.annualInspectDue() != null) lic.setAnnualInspectDue(req.annualInspectDue()); if (req.remark() != null) lic.setRemark(req.remark()); if (req.owner() != null) lic.setOwner(req.owner()); applyExpiryState(lic); return ApiResp.ok(repo.save(lic)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!repo.existsById(id)) { throw new NotFoundException("branch license not found: " + id); } repo.deleteById(id); return ApiResp.ok(null); } // ---------- 年检分级预警端点 ---------- public record AnnualInspectAlert( Long id, String org, String licenseType, String name, String regNo, String annualInspectDue, long daysToInspect, String level) { } /** * 年检周期提醒(需求 §6「有效期提醒/年检周期提醒」)。 * level: 逾期(已过年检截止) / 紧急(<=30天) / 临近(<=60天) / 关注(<=days参数)。 */ @GetMapping("/alerts/annual-inspect") public ApiResp> annualInspectAlerts( @RequestParam(required = false, defaultValue = "60") int days) { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (BranchLicense lic : repo.findAll()) { LocalDate due = parseDateOrNull(lic.getAnnualInspectDue()); if (due == null) { continue; } long d = ChronoUnit.DAYS.between(today, due); if (d > days) { continue; } String level = d < 0 ? "逾期" : d <= 30 ? "紧急" : d <= 60 ? "临近" : "关注"; out.add(new AnnualInspectAlert(lic.getId(), lic.getOrg(), lic.getLicenseType(), lic.getName(), lic.getRegNo(), lic.getAnnualInspectDue(), d, level)); } out.sort((a, b) -> Long.compare(a.daysToInspect(), b.daysToInspect())); return ApiResp.ok(out); } // ---------- 有效期预警端点 ---------- public record ExpiryAlert( Long id, String org, String licenseType, String name, String expiryDate, long daysToExpiry, String level) { } /** * 有效期到期预警(与年检提醒分开,对应营业执照有效期/资质证书年检后续期)。 */ @GetMapping("/alerts/expiry") public ApiResp> expiryAlerts( @RequestParam(required = false, defaultValue = "90") int days) { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (BranchLicense lic : repo.findAll()) { LocalDate exp = parseDateOrNull(lic.getExpiryDate()); if (exp == null) { continue; } long d = ChronoUnit.DAYS.between(today, exp); if (d > days) { continue; } String level = d < 0 ? "逾期" : d <= 30 ? "紧急" : d <= 60 ? "临近" : "关注"; out.add(new ExpiryAlert(lic.getId(), lic.getOrg(), lic.getLicenseType(), lic.getName(), lic.getExpiryDate(), d, level)); } out.sort((a, b) -> Long.compare(a.daysToExpiry(), b.daysToExpiry())); return ApiResp.ok(out); } // ---------- helpers ---------- /** * 有效期状态机(读时刷新,不落库):有效 / 临期(<=60天) / 已过期。 * 年检状态按 annualInspectDue 独立刷新:正常 / 临检(<=30天) / 逾期 / 免检(空)。 */ private void applyExpiryState(BranchLicense lic) { LocalDate today = LocalDate.now(); // 有效期状态 LocalDate exp = parseDateOrNull(lic.getExpiryDate()); if (exp == null) { lic.setStatus("有效"); } else { long d = ChronoUnit.DAYS.between(today, exp); if (d < 0) { lic.setStatus("已过期"); } else if (d <= 60) { lic.setStatus("临期"); } else { lic.setStatus("有效"); } } // 年检状态 LocalDate inspectDue = parseDateOrNull(lic.getAnnualInspectDue()); if (inspectDue == null) { lic.setAnnualInspectStatus("免检"); } else { long d = ChronoUnit.DAYS.between(today, inspectDue); if (d < 0) { lic.setAnnualInspectStatus("逾期"); } else if (d <= 30) { lic.setAnnualInspectStatus("临检"); } else { lic.setAnnualInspectStatus("正常"); } } } private void refreshExpiryView(BranchLicense lic) { applyExpiryState(lic); } private static LocalDate parseDateOrNull(String s) { if (s == null || s.isBlank()) { return null; } try { return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); } catch (DateTimeParseException | IndexOutOfBoundsException e) { return null; } } }