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.CustomerVisit; import com.kaidi.oa.repository.CustomerRepository; import com.kaidi.oa.repository.CustomerVisitRepository; 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; /** * 市场部·客户拜访纪要与跟进提醒(支持移动端现场录入)。覆盖拜访纪要 CRUD、按客户/拜访人筛选、 * 标记跟进完成(POST /{id}/mark-followed),以及跟进提醒聚合(GET /reminders:把未完成且 * 下次跟进日落在窗口内的拜访按 逾期 / 今日 / 临近 分级,杜绝跟进遗漏)。 * * 读口含客户跟进明细(属销售线索类业务),写口默认受 default-deny(ADMIN/APPROVER) 保护。 */ @RestController @RequestMapping("/api/oa/customer-visits") public class CustomerVisitController { private final CustomerVisitRepository visitRepo; private final CustomerRepository customerRepo; public CustomerVisitController(CustomerVisitRepository visitRepo, CustomerRepository customerRepo) { this.visitRepo = visitRepo; this.customerRepo = customerRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) Long customerId, @RequestParam(required = false) String owner) { if (customerId != null) { return ApiResp.ok(visitRepo.findByCustomerId(customerId)); } if (owner != null && !owner.isBlank()) { return ApiResp.ok(visitRepo.findByOwner(owner)); } return ApiResp.ok(visitRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(visitRepo.findById(id) .orElseThrow(() -> new NotFoundException("customer visit not found: " + id))); } public record VisitRequest( Long customerId, String customerName, String visitType, String contactPerson, String visitDate, String summary, String nextFollowDate, String nextPlan, Boolean followed, String orgUnit, String location, String owner) { } @PostMapping public ApiResp create(@RequestBody VisitRequest req) { if (req.customerId() == null) { throw new ApiException(400, "客户(customerId) 不能为空"); } // 校验客户存在,杜绝悬挂引用。 var customer = customerRepo.findById(req.customerId()) .orElseThrow(() -> new ApiException(400, "客户不存在:" + req.customerId())); if (req.summary() == null || req.summary().isBlank()) { throw new ApiException(400, "拜访纪要(summary) 不能为空"); } CustomerVisit v = new CustomerVisit(); v.setCustomerId(req.customerId()); v.setCustomerName(req.customerName() == null || req.customerName().isBlank() ? customer.getName() : req.customerName()); v.setVisitType(req.visitType() == null || req.visitType().isBlank() ? "上门" : req.visitType()); v.setContactPerson(req.contactPerson()); v.setVisitDate(req.visitDate() == null || req.visitDate().isBlank() ? LocalDate.now().toString() : req.visitDate()); v.setSummary(req.summary()); v.setNextFollowDate(req.nextFollowDate()); v.setNextPlan(req.nextPlan()); v.setFollowed(req.followed() != null ? req.followed() : Boolean.FALSE); v.setOrgUnit(req.orgUnit()); v.setLocation(req.location()); v.setOwner(req.owner()); v.setCreatedAt(Instant.now()); return ApiResp.ok(visitRepo.save(v)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody VisitRequest req) { CustomerVisit v = visitRepo.findById(id) .orElseThrow(() -> new NotFoundException("customer visit not found: " + id)); if (req.customerName() != null && !req.customerName().isBlank()) v.setCustomerName(req.customerName()); if (req.visitType() != null && !req.visitType().isBlank()) v.setVisitType(req.visitType()); if (req.contactPerson() != null) v.setContactPerson(req.contactPerson()); if (req.visitDate() != null) v.setVisitDate(req.visitDate()); if (req.summary() != null && !req.summary().isBlank()) v.setSummary(req.summary()); if (req.nextFollowDate() != null) v.setNextFollowDate(req.nextFollowDate()); if (req.nextPlan() != null) v.setNextPlan(req.nextPlan()); if (req.followed() != null) v.setFollowed(req.followed()); if (req.orgUnit() != null) v.setOrgUnit(req.orgUnit()); if (req.location() != null) v.setLocation(req.location()); if (req.owner() != null) v.setOwner(req.owner()); return ApiResp.ok(visitRepo.save(v)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!visitRepo.existsById(id)) { throw new NotFoundException("customer visit not found: " + id); } visitRepo.deleteById(id); return ApiResp.ok(null); } /** POST /{id}/mark-followed → 标记本次跟进已完成(从提醒列表移除)。 */ @PostMapping("/{id}/mark-followed") public ApiResp markFollowed(@PathVariable Long id) { CustomerVisit v = visitRepo.findById(id) .orElseThrow(() -> new NotFoundException("customer visit not found: " + id)); v.setFollowed(Boolean.TRUE); return ApiResp.ok(visitRepo.save(v)); } // ---------- 跟进提醒聚合 ---------- public record Reminder(Long visitId, Long customerId, String customerName, String owner, String nextFollowDate, String nextPlan, long daysToFollow, String level) { } /** * GET /reminders → 跟进提醒:未完成(followed=false) 且下次跟进日落在 [今天-逾期, 今天+days] * 的拜访。level:逾期 / 今日 / 临近(<=3天) / 关注(<=days)。按距离升序,逾期最前。 */ @GetMapping("/reminders") public ApiResp> reminders(@RequestParam(required = false, defaultValue = "14") int days) { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (CustomerVisit v : visitRepo.findByFollowed(Boolean.FALSE)) { LocalDate next = parseDateOrNull(v.getNextFollowDate()); if (next == null) { continue; } long daysTo = ChronoUnit.DAYS.between(today, next); if (daysTo > days) { continue; } String level = daysTo < 0 ? "逾期" : daysTo == 0 ? "今日" : daysTo <= 3 ? "临近" : "关注"; out.add(new Reminder(v.getId(), v.getCustomerId(), v.getCustomerName(), v.getOwner(), v.getNextFollowDate(), v.getNextPlan(), daysTo, level)); } out.sort((a, b) -> Long.compare(a.daysToFollow(), b.daysToFollow())); return ApiResp.ok(out); } 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; } } }