package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.Intel; import com.kaidi.oa.domain.Opportunity; import com.kaidi.oa.domain.SysUser; import com.kaidi.oa.repository.IntelRepository; import com.kaidi.oa.repository.OpportunityRepository; import jakarta.servlet.http.HttpServletRequest; import org.springframework.transaction.annotation.Transactional; 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.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.util.List; /** * Unified intelligence center collecting business leads. category is one of * 招标公告 / 中标公示 / 政策法规 / 竞品动态 / 客户动态 / 行业资讯; status is one of * 待评估 / 跟进中 / 已转商机 / 已忽略. An intelligence lead may link to a CRM * opportunity (opportunityId) once converted. */ @RestController @RequestMapping("/api/oa/intel") public class IntelController { private final IntelRepository intelRepo; private final OpportunityRepository opportunityRepo; private final CurrentUserResolver currentUser; public IntelController(IntelRepository intelRepo, OpportunityRepository opportunityRepo, CurrentUserResolver currentUser) { this.intelRepo = intelRepo; this.opportunityRepo = opportunityRepo; this.currentUser = currentUser; } @GetMapping public ApiResp> list(@RequestParam(required = false) String category, @RequestParam(required = false) String status) { List list; if (category != null && !category.isBlank()) { list = intelRepo.findByCategory(category); } else if (status != null && !status.isBlank()) { list = intelRepo.findByStatus(status); } else { list = intelRepo.findAll(); } return ApiResp.ok(list); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(intelRepo.findById(id) .orElseThrow(() -> new NotFoundException("intel not found: " + id))); } public record CreateIntelRequest( String title, String category, String source, String region, String publishDate, String captureDate, String keyword, String refUrl, String summary, String status, Long opportunityId, String owner) { } @PostMapping public ApiResp create(@RequestBody CreateIntelRequest req, HttpServletRequest http) { if (req.title() == null || req.title().isBlank()) { throw new ApiException(400, "title is required"); } Intel i = new Intel(); i.setTitle(req.title()); i.setCategory(req.category()); i.setSource(req.source()); i.setRegion(req.region()); i.setPublishDate(req.publishDate()); i.setCaptureDate(req.captureDate()); i.setKeyword(req.keyword()); i.setRefUrl(req.refUrl()); i.setSummary(req.summary()); i.setStatus(req.status() == null || req.status().isBlank() ? "待评估" : req.status()); i.setOpportunityId(req.opportunityId()); // 归属由服务端按当前登录用户定调, 未登录才回退客户端值, 不收任意 owner。 SysUser actor = currentUser.resolve(http); i.setOwner(actor != null ? currentUser.resolveLabel(http) : req.owner()); i.setCreatedAt(Instant.now()); return ApiResp.ok(intelRepo.save(i)); } /** * POST /{id}/convert-opportunity -> 情报转商机。 Reads the intelligence lead, * creates a fresh CRM opportunity carrying the lead's title/source/owner, * writes the new opportunityId back onto the lead and flips its status to * 已转商机, then returns the new opportunity. Idempotent: a lead already in * 已转商机 OR already carrying an opportunityId is rejected so a second click — * or a first click whose status write-back failed — cannot spawn a duplicate商机. * The whole conversion runs in one transaction: if the lead write-back fails the * freshly created opportunity is rolled back too, so no orphan商机 is left behind. */ @Transactional @PostMapping("/{id}/convert-opportunity") public ApiResp convertOpportunity(@PathVariable Long id) { Intel i = intelRepo.findById(id) .orElseThrow(() -> new NotFoundException("intel not found: " + id)); // Idempotency: treat the lead as already converted if EITHER the status is // 已转商机 OR an opportunity is already linked (OR, not AND) so a partial prior // conversion (status saved but link not, or vice versa) still blocks a dup. if (i.getOpportunityId() != null || "已转商机".equals(i.getStatus())) { throw new ApiException(400, "该情报已转商机, 请勿重复转换"); } Opportunity o = new Opportunity(); o.setName(i.getTitle()); o.setSource(i.getSource()); o.setOwner(i.getOwner()); o.setProjectType(i.getCategory()); o.setStage("线索"); o.setProbability(0); o.setAmount(Money.ZERO); o.setStatus("进行中"); o.setCreatedAt(Instant.now()); Opportunity saved = opportunityRepo.save(o); // --> 回写情报: 关联新商机 id 并标记已转商机。 i.setOpportunityId(saved.getId()); i.setStatus("已转商机"); intelRepo.save(i); return ApiResp.ok(saved); } }