package com.kaidi.oa.web; import com.kaidi.oa.common.Money; import com.fasterxml.jackson.databind.JsonNode; 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.Patent; import com.kaidi.oa.domain.Policy; import com.kaidi.oa.domain.RdExpense; import com.kaidi.oa.repository.OaSettingRepository; import com.kaidi.oa.repository.PatentRepository; import com.kaidi.oa.repository.PolicyRepository; import com.kaidi.oa.repository.RdExpenseRepository; import org.springframework.web.bind.annotation.DeleteMapping; 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.PutMapping; 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.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** * Declarable-policy library (申报政策库) + a lightweight matcher that recommends * policies for the enterprise. authority is the issuing body (科技 / 工信 / 发改 / * 人社 …); category is one of 资质 / 资金 / 荣誉 / 人才; tags is a comma-separated * keyword list. The matcher compares policy tags/title against an enterprise * profile of keywords and orders hits by deadline (earliest first). * * 需甲方规则: 企业画像(标签集合)与匹配算法属甲方规则。当前为「标签关键词命中数 + * 到期排序」的简化框架;企业标签优先读 setting key {@code policy-company-profile} * 的 JSON 数组,缺省回退到 DEFAULT_PROFILE。生产应按资质年限/规模/行业打分。 */ @RestController @RequestMapping("/api/oa/policies") public class PolicyController { /** 需甲方规则: 默认企业画像标签。 */ static final List DEFAULT_PROFILE = List.of("高企", "专精特新", "软件", "环保", "水务", "研发"); static final String PROFILE_SETTING_KEY = "policy-company-profile"; private final PolicyRepository policyRepo; private final OaSettingRepository settingRepo; private final ObjectMapper objectMapper; private final PatentRepository patentRepo; private final RdExpenseRepository rdExpenseRepo; public PolicyController(PolicyRepository policyRepo, OaSettingRepository settingRepo, ObjectMapper objectMapper, PatentRepository patentRepo, RdExpenseRepository rdExpenseRepo) { this.policyRepo = policyRepo; this.settingRepo = settingRepo; this.objectMapper = objectMapper; this.patentRepo = patentRepo; this.rdExpenseRepo = rdExpenseRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) String category, @RequestParam(required = false) String authority, @RequestParam(required = false) String status) { List list; if (category != null && !category.isBlank()) { list = policyRepo.findByCategory(category); } else if (authority != null && !authority.isBlank()) { list = policyRepo.findByAuthority(authority); } else if (status != null && !status.isBlank()) { list = policyRepo.findByStatus(status); } else { list = policyRepo.findAll(); } return ApiResp.ok(list); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(policyRepo.findById(id) .orElseThrow(() -> new NotFoundException("policy not found: " + id))); } public record PolicyRequest( String title, String authority, String category, String tags, String conditions, Double fundAmount, String deadline, String status) { } @PostMapping public ApiResp create(@RequestBody PolicyRequest req) { if (req.title() == null || req.title().isBlank()) { throw new ApiException(400, "title is required"); } Policy p = new Policy(); p.setTitle(req.title()); p.setAuthority(req.authority()); p.setCategory(req.category()); p.setTags(req.tags()); p.setConditions(req.conditions()); p.setFundAmount(Money.of(req.fundAmount() == null ? 0d : req.fundAmount())); p.setDeadline(req.deadline()); p.setStatus(req.status() == null || req.status().isBlank() ? "可申报" : req.status()); p.setCreatedAt(Instant.now()); return ApiResp.ok(policyRepo.save(p)); } @PutMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody PolicyRequest req) { Policy p = policyRepo.findById(id) .orElseThrow(() -> new NotFoundException("policy not found: " + id)); if (req.title() != null) { if (req.title().isBlank()) { throw new ApiException(400, "title is required"); } p.setTitle(req.title()); } if (req.authority() != null) p.setAuthority(req.authority()); if (req.category() != null) p.setCategory(req.category()); if (req.tags() != null) p.setTags(req.tags()); if (req.conditions() != null) p.setConditions(req.conditions()); if (req.fundAmount() != null) p.setFundAmount(Money.of(req.fundAmount())); if (req.deadline() != null) p.setDeadline(req.deadline()); if (req.status() != null && !req.status().isBlank()) p.setStatus(req.status()); return ApiResp.ok(policyRepo.save(p)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!policyRepo.existsById(id)) { throw new NotFoundException("policy not found: " + id); } policyRepo.deleteById(id); return ApiResp.ok(null); } /** 匹配结果:政策 + 命中的画像标签 + 命中数(matchScore)。 */ public record MatchedPolicy(Policy policy, List matchedTags, int matchScore) { } /** * GET /policies/match -> 按企业画像匹配可申报政策。画像标签来自 setting * {@code policy-company-profile}(JSON 数组),缺省用 DEFAULT_PROFILE;亦可用 * query 参数 tags=高企,软件 临时覆盖。命中规则:政策 tags/title 含画像标签即命中; * 仅返回命中数>0 且非「已截止」的政策,按命中数降序、到期日升序排序。 */ @GetMapping("/match") public ApiResp> match(@RequestParam(required = false) String tags) { List profile = resolveProfile(tags); List hits = new ArrayList<>(); for (Policy p : policyRepo.findAll()) { if ("已截止".equals(p.getStatus())) continue; String haystack = ((p.getTags() == null ? "" : p.getTags()) + " " + (p.getTitle() == null ? "" : p.getTitle())).toLowerCase(); List matched = new ArrayList<>(); for (String tag : profile) { if (tag != null && !tag.isBlank() && haystack.contains(tag.toLowerCase())) { matched.add(tag); } } if (!matched.isEmpty()) { hits.add(new MatchedPolicy(p, matched, matched.size())); } } hits.sort(Comparator .comparingInt(MatchedPolicy::matchScore).reversed() .thenComparing(m -> m.policy().getDeadline() == null ? "9999-12-31" : m.policy().getDeadline())); return ApiResp.ok(hits); } /** GET /policies/profile -> 当前企业画像标签(供前端展示/编辑)。 */ @GetMapping("/profile") public ApiResp> profile() { Map out = new LinkedHashMap<>(); out.put("tags", resolveProfile(null)); return ApiResp.ok(out); } /** * 精准打分引擎(Gap 1 [low] 补完):在「标签命中数+截止排序」简化版基础上, * 叠加企业规模/行业/知识产权年限维度,计算每条政策对本企业的综合匹配得分。 * *

得分维度(总满分100): *

    *
  • 标签命中(40分):每命中1个画像标签+8分,最高40分;
  • *
  • 企业规模匹配(20分):policy.conditions 或 tags 含规模关键词与传入 scale 匹配;
  • *
  • 行业匹配(20分):policy.conditions 或 tags 含行业关键词与传入 industry 匹配;
  • *
  • 知识产权年限(20分):有效 IP 数量 × 年限因子(有 IP 且未过期≥5年得满分)。
  • *
* 参数全部可选:不传则该维度视为0分。 * 结果按总分降序排列,仅返回总分>0 且非「已截止」的政策。 */ @GetMapping("/score") public ApiResp>> score( @RequestParam(required = false) String tags, @RequestParam(required = false) String industry, @RequestParam(required = false) String scale, @RequestParam(required = false) Boolean includeExpired) { List profile = resolveProfile(tags); boolean showExpired = Boolean.TRUE.equals(includeExpired); // 自动从知识产权库取有效 IP(打分用) Set validIpStatuses = Set.of("已受理", "已授权", "已转让"); long validIpCount = patentRepo.findAll().stream() .filter(p -> p.getStatus() != null && validIpStatuses.contains(p.getStatus())) .count(); // 知识产权有效年限:平均算到期日距今天数 long avgIpRemainingDays = patentRepo.findAll().stream() .filter(p -> p.getStatus() != null && validIpStatuses.contains(p.getStatus()) && p.getFeeDueDate() != null && !p.getFeeDueDate().isBlank()) .mapToLong(p -> { try { LocalDate due = LocalDate.parse(p.getFeeDueDate().substring(0, 10)); return ChronoUnit.DAYS.between(LocalDate.now(), due); } catch (Exception e) { return 0L; } }) .average() .orElse(0.0) > 0 ? (long) patentRepo.findAll().stream() .filter(p -> p.getStatus() != null && validIpStatuses.contains(p.getStatus()) && p.getFeeDueDate() != null && !p.getFeeDueDate().isBlank()) .mapToLong(p -> { try { LocalDate due = LocalDate.parse(p.getFeeDueDate().substring(0, 10)); return Math.max(0, ChronoUnit.DAYS.between(LocalDate.now(), due)); } catch (Exception e) { return 0L; } }).average().orElse(0.0) : 0L; List> result = new ArrayList<>(); for (Policy p : policyRepo.findAll()) { if (!showExpired && "已截止".equals(p.getStatus())) { continue; } String haystack = ((p.getTags() == null ? "" : p.getTags()) + " " + (p.getTitle() == null ? "" : p.getTitle()) + " " + (p.getConditions() == null ? "" : p.getConditions())).toLowerCase(); // (1) 标签命中得分(max 40) List matchedTags = new ArrayList<>(); for (String t : profile) { if (t != null && !t.isBlank() && haystack.contains(t.toLowerCase())) { matchedTags.add(t); } } int tagScore = Math.min(40, matchedTags.size() * 8); // (2) 行业匹配得分(max 20) int industryScore = 0; if (industry != null && !industry.isBlank() && haystack.contains(industry.toLowerCase())) { industryScore = 20; } // (3) 企业规模匹配(max 20) int scaleScore = 0; if (scale != null && !scale.isBlank()) { if (haystack.contains(scale.toLowerCase())) { scaleScore = 20; } else if ("中小企业".contains(scale) && (haystack.contains("中小") || haystack.contains("中型") || haystack.contains("小型"))) { scaleScore = 10; } } // (4) 知识产权年限得分(max 20) int ipScore = 0; if (validIpCount > 0) { // IP 数量加成(每有 1 件有效 IP +4 分,最多 12) ipScore += (int) Math.min(12, validIpCount * 4); // 平均有效年限加成(剩余≥5年满 8 分;1-5年按比例;<1年 0) if (avgIpRemainingDays >= 365 * 5) { ipScore += 8; } else if (avgIpRemainingDays >= 365) { ipScore += (int) (8.0 * avgIpRemainingDays / (365 * 5)); } ipScore = Math.min(20, ipScore); } int totalScore = tagScore + industryScore + scaleScore + ipScore; if (totalScore == 0) { continue; } Map row = new LinkedHashMap<>(); row.put("policyId", p.getId()); row.put("policyTitle", p.getTitle()); row.put("category", p.getCategory()); row.put("authority", p.getAuthority()); row.put("deadline", p.getDeadline()); row.put("status", p.getStatus()); row.put("fundAmount", Money.nz(p.getFundAmount())); row.put("totalScore", totalScore); row.put("tagScore", tagScore); row.put("industryScore", industryScore); row.put("scaleScore", scaleScore); row.put("ipScore", ipScore); row.put("matchedTags", matchedTags); row.put("validIpCount", validIpCount); result.add(row); } result.sort((a, b) -> Integer.compare((int) b.get("totalScore"), (int) a.get("totalScore"))); return ApiResp.ok(result); } private List resolveProfile(String override) { if (override != null && !override.isBlank()) { return Arrays.stream(override.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList(); } try { return settingRepo.findBySettingKey(PROFILE_SETTING_KEY) .map(s -> { try { JsonNode node = objectMapper.readTree(s.getValueJson()); if (node != null && node.isArray()) { List tags = new ArrayList<>(); node.forEach(n -> tags.add(n.asText())); if (!tags.isEmpty()) return tags; } } catch (Exception ignore) { // fall through to default } return DEFAULT_PROFILE; }) .orElse(DEFAULT_PROFILE); } catch (Exception e) { return DEFAULT_PROFILE; } } }