You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
880 lines
42 KiB
880 lines
42 KiB
package com.mydreamjob.offer;
|
|
|
|
import com.mydreamjob.application.Application;
|
|
import com.mydreamjob.application.ApplicationRepository;
|
|
import com.mydreamjob.audit.AuditContext;
|
|
import com.mydreamjob.audit.AuditService;
|
|
import com.mydreamjob.auth.User;
|
|
import com.mydreamjob.auth.UserRepository;
|
|
import com.mydreamjob.common.exception.BusinessException;
|
|
import com.mydreamjob.common.exception.ErrorCode;
|
|
import com.mydreamjob.company.Company;
|
|
import com.mydreamjob.company.CompanyRepository;
|
|
import com.mydreamjob.company.VerifiedCompanyGuard;
|
|
import com.mydreamjob.job.Job;
|
|
import com.mydreamjob.job.JobService;
|
|
import com.mydreamjob.notification.NotificationService;
|
|
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
|
|
import com.mydreamjob.offer.dto.CounterOfferCreateRequest;
|
|
import com.mydreamjob.offer.dto.OfferCreateRequest;
|
|
import com.mydreamjob.offer.dto.OfferStatusRequest;
|
|
import com.mydreamjob.offer.dto.OfferUpdateRequest;
|
|
import jakarta.persistence.EntityManager;
|
|
import jakarta.persistence.PersistenceContext;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.dao.OptimisticLockingFailureException;
|
|
import org.springframework.data.domain.Page;
|
|
import org.springframework.data.domain.PageRequest;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.time.temporal.ChronoUnit;
|
|
import java.util.*;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* Offer 管理 Service
|
|
*
|
|
* <p>权限模型:
|
|
* <ul>
|
|
* <li>企业只能操作自己公司创建的 Offer(companyId 与当前企业一致);</li>
|
|
* <li>候选人只能查看/响应属于自己的 Offer(candidateId 与当前用户一致);</li>
|
|
* <li>企业不能通过请求参数传入 companyId/candidateId 绕过权限——身份一律来自 JWT。</li>
|
|
* </ul>
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class OfferService {
|
|
|
|
/** 定时任务(系统)操作者占位 userId,不指向真实用户行(history.operator_id 无外键) */
|
|
public static final Long SYSTEM_OPERATOR_ID = 0L;
|
|
|
|
private static final String ACTIVE_KEY = "ACTIVE";
|
|
private static final int MAX_PAGE_SIZE = 100;
|
|
|
|
private final OfferRepository offerRepository;
|
|
private final OfferStatusHistoryRepository historyRepository;
|
|
private final OfferReminderRepository reminderRepository;
|
|
private final OfferCounterOfferRepository counterOfferRepository;
|
|
private final ApplicationRepository applicationRepository;
|
|
private final JobService jobService;
|
|
private final CompanyRepository companyRepository;
|
|
private final UserRepository userRepository;
|
|
private final NotificationService notificationService;
|
|
private final AuditService auditService;
|
|
/** R01-D4-1:Offer 状态变更(ACCEPTED/EXPIRED 履约聚合)后登记评分快照刷新。 */
|
|
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
|
|
|
|
@PersistenceContext
|
|
private EntityManager entityManager;
|
|
|
|
/**
|
|
* 操作者角色(由 JWT 身份在 Service 层推导,绝不信任请求参数)
|
|
*/
|
|
private enum ActorRole { COMPANY, CANDIDATE }
|
|
|
|
// ── 创建(企业)────────────────────────────────────────────
|
|
|
|
/**
|
|
* 企业创建 Offer 草稿。
|
|
* 前置条件:投递存在、岗位属于当前企业、投递状态可发 Offer、该投递无有效 Offer。
|
|
*/
|
|
@Transactional
|
|
public Offer createOffer(Long operatorUserId, OfferCreateRequest req, AuditContext ctx) {
|
|
Company company = requireCompany(operatorUserId);
|
|
Application app = applicationRepository.findById(req.getApplicationId())
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
|
|
|
|
Job job = jobService.getJob(app.getJobId());
|
|
if (!company.getId().equals(job.getCompanyId())) {
|
|
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
|
|
}
|
|
if (!isApplicationEligible(app.getStatus())) {
|
|
throw new BusinessException(ErrorCode.OFFER_APPLICATION_NOT_ELIGIBLE);
|
|
}
|
|
if (offerRepository.existsByApplicationIdAndActiveKeyNotNull(app.getId())) {
|
|
throw new BusinessException(ErrorCode.DUPLICATE_OFFER);
|
|
}
|
|
validateSalaryRange(req.getSalaryMin(), req.getSalaryMax());
|
|
|
|
Offer offer = Offer.builder()
|
|
.applicationId(app.getId())
|
|
.jobId(job.getId())
|
|
.companyId(company.getId())
|
|
.candidateId(app.getStudentUserId())
|
|
.title(req.getTitle().trim())
|
|
.salaryMin(req.getSalaryMin())
|
|
.salaryMax(req.getSalaryMax())
|
|
.salaryCurrency(defaultIfBlank(req.getSalaryCurrency(), "CNY"))
|
|
.salaryPeriod(defaultIfBlank(req.getSalaryPeriod(), "MONTH"))
|
|
.workCity(req.getWorkCity() == null ? null : req.getWorkCity().trim())
|
|
.employmentType(req.getEmploymentType())
|
|
.probationMonths(req.getProbationMonths() == null ? 0 : req.getProbationMonths())
|
|
.benefits(req.getBenefits())
|
|
.description(req.getDescription().trim())
|
|
.expireAt(req.getExpireAt())
|
|
.onboardingDate(req.getOnboardingDate())
|
|
.baseSalary(req.getBaseSalary())
|
|
.housingAllowance(req.getHousingAllowance())
|
|
.annualBonusMonths(req.getAnnualBonusMonths())
|
|
.otherAllowances(req.getOtherAllowances())
|
|
.status(OfferStatus.DRAFT.name())
|
|
.activeKey(ACTIVE_KEY)
|
|
.createdBy(operatorUserId)
|
|
.updatedBy(operatorUserId)
|
|
.build();
|
|
try {
|
|
offer = offerRepository.save(offer);
|
|
} catch (org.springframework.dao.DataIntegrityViolationException e) {
|
|
// 唯一索引 uk_offers_active 兜底(并发创建)
|
|
log.info("Duplicate active offer detected for application {}", app.getId());
|
|
throw new BusinessException(ErrorCode.DUPLICATE_OFFER);
|
|
}
|
|
recordHistory(offer.getId(), null, OfferStatus.DRAFT.name(),
|
|
operatorUserId, ActorRole.COMPANY.name(), "Offer created", ctx);
|
|
auditService.log(operatorUserId, "CREATE_OFFER", "OFFER", offer.getId(),
|
|
"Offer created for application " + app.getId(), ctx);
|
|
// 创建草稿不发送通知
|
|
return offer;
|
|
}
|
|
|
|
// ── 查询 ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 列表:候选人查看自己收到的、企业查看自己发出的 Offer。
|
|
* 支持 status、applicationId 筛选,分页,createdAt 倒序。
|
|
*/
|
|
public Page<Offer> listOffers(Long userId, String status, Long applicationId, int page, int size) {
|
|
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
|
|
User user = loadUser(userId);
|
|
PageRequest pr = PageRequest.of(page, size);
|
|
|
|
if (hasRole(user, User.Role.COMPANY)) {
|
|
Company company = requireCompany(userId);
|
|
return listByCompany(company.getId(), status, applicationId, pr);
|
|
}
|
|
if (hasRole(user, User.Role.STUDENT)) {
|
|
return listByCandidate(userId, status, applicationId, pr);
|
|
}
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
|
|
private Page<Offer> listByCompany(Long companyId, String status, Long applicationId, PageRequest pr) {
|
|
boolean hasStatus = status != null && !status.isBlank();
|
|
if (hasStatus && applicationId != null)
|
|
return offerRepository.findByCompanyIdAndStatusAndApplicationIdOrderByCreatedAtDesc(
|
|
companyId, status, applicationId, pr);
|
|
if (hasStatus)
|
|
return offerRepository.findByCompanyIdAndStatusOrderByCreatedAtDesc(companyId, status, pr);
|
|
if (applicationId != null)
|
|
return offerRepository.findByCompanyIdAndApplicationIdOrderByCreatedAtDesc(companyId, applicationId, pr);
|
|
return offerRepository.findByCompanyIdOrderByCreatedAtDesc(companyId, pr);
|
|
}
|
|
|
|
private Page<Offer> listByCandidate(Long candidateId, String status, Long applicationId, PageRequest pr) {
|
|
boolean hasStatus = status != null && !status.isBlank();
|
|
if (hasStatus && applicationId != null)
|
|
return offerRepository.findByCandidateIdAndStatusAndApplicationIdOrderByCreatedAtDesc(
|
|
candidateId, status, applicationId, pr);
|
|
if (hasStatus)
|
|
return offerRepository.findByCandidateIdAndStatusOrderByCreatedAtDesc(candidateId, status, pr);
|
|
if (applicationId != null)
|
|
return offerRepository.findByCandidateIdAndApplicationIdOrderByCreatedAtDesc(candidateId, applicationId, pr);
|
|
return offerRepository.findByCandidateIdOrderByCreatedAtDesc(candidateId, pr);
|
|
}
|
|
|
|
/** 详情:仅目标候选人、所属企业可访问 */
|
|
public Offer getOffer(Long offerId, Long userId) {
|
|
requireVerifiedIfCompany(userId);
|
|
Offer offer = requireOffer(offerId);
|
|
assertViewAccess(offer, userId);
|
|
return offer;
|
|
}
|
|
|
|
/** 状态时间线:访问规则同详情 */
|
|
public List<Map<String, Object>> getTimeline(Long offerId, Long userId) {
|
|
getOffer(offerId, userId);
|
|
return historyRepository.findByOfferIdOrderByCreatedAtAsc(offerId)
|
|
.stream().map(this::toHistoryView).collect(Collectors.toList());
|
|
}
|
|
|
|
// ── 修改(仅 DRAFT,企业)──────────────────────────────────
|
|
|
|
/**
|
|
* 修改 Offer:仅 DRAFT 状态可修改;其余状态返回业务错误。
|
|
* 身份字段(application/job/company/candidate)不可变更。
|
|
*/
|
|
@Transactional
|
|
public Offer updateOffer(Long operatorUserId, Long offerId, OfferUpdateRequest req, AuditContext ctx) {
|
|
Company company = requireCompany(operatorUserId);
|
|
Offer offer = requireOffer(offerId);
|
|
if (!company.getId().equals(offer.getCompanyId())) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
if (!OfferStatus.DRAFT.name().equals(offer.getStatus())) {
|
|
throw new BusinessException(ErrorCode.OFFER_NOT_EDITABLE);
|
|
}
|
|
validateSalaryRange(req.getSalaryMin(), req.getSalaryMax());
|
|
if (req.getExpireAt() != null && !req.getExpireAt().isAfter(LocalDateTime.now())) {
|
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "expireAt must be in the future");
|
|
}
|
|
|
|
if (req.getTitle() != null) offer.setTitle(req.getTitle().trim());
|
|
if (req.getSalaryMin() != null) offer.setSalaryMin(req.getSalaryMin());
|
|
if (req.getSalaryMax() != null) offer.setSalaryMax(req.getSalaryMax());
|
|
if (req.getSalaryCurrency() != null) offer.setSalaryCurrency(req.getSalaryCurrency());
|
|
if (req.getSalaryPeriod() != null) offer.setSalaryPeriod(req.getSalaryPeriod());
|
|
if (req.getWorkCity() != null) offer.setWorkCity(req.getWorkCity().trim());
|
|
if (req.getEmploymentType() != null) offer.setEmploymentType(req.getEmploymentType());
|
|
if (req.getProbationMonths() != null) offer.setProbationMonths(req.getProbationMonths());
|
|
if (req.getBenefits() != null) offer.setBenefits(req.getBenefits());
|
|
if (req.getDescription() != null) offer.setDescription(req.getDescription().trim());
|
|
if (req.getExpireAt() != null) offer.setExpireAt(req.getExpireAt());
|
|
if (req.getOnboardingDate() != null) offer.setOnboardingDate(req.getOnboardingDate());
|
|
if (req.getBaseSalary() != null) offer.setBaseSalary(req.getBaseSalary());
|
|
if (req.getHousingAllowance() != null) offer.setHousingAllowance(req.getHousingAllowance());
|
|
if (req.getAnnualBonusMonths() != null) offer.setAnnualBonusMonths(req.getAnnualBonusMonths());
|
|
if (req.getOtherAllowances() != null) offer.setOtherAllowances(req.getOtherAllowances().trim());
|
|
offer.setUpdatedBy(operatorUserId);
|
|
|
|
offer = offerRepository.save(offer);
|
|
auditService.log(operatorUserId, "UPDATE_OFFER", "OFFER", offer.getId(),
|
|
"Offer draft updated", ctx);
|
|
return offer;
|
|
}
|
|
|
|
// ── 状态变更 ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Offer 状态流转。
|
|
* <ul>
|
|
* <li>角色:DRAFT→SENT 仅企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 仅候选人;EXPIRED 仅系统。</li>
|
|
* <li>幂等:目标状态与当前一致时直接返回,不产生历史;并发冲突经乐观锁重读判定。</li>
|
|
* <li>过期保护:非 EXPIRED 目标且已过期的 Offer 拒绝流转。</li>
|
|
* </ul>
|
|
*/
|
|
@Transactional
|
|
public Offer transitionStatus(Long operatorUserId, Long offerId, OfferStatusRequest req, AuditContext ctx) {
|
|
User user = loadUser(operatorUserId);
|
|
boolean isCompany = hasRole(user, User.Role.COMPANY);
|
|
boolean isCandidate = hasRole(user, User.Role.STUDENT);
|
|
ActorRole actor = isCompany ? ActorRole.COMPANY : (isCandidate ? ActorRole.CANDIDATE : null);
|
|
if (actor == null) throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
|
|
Company currentCompany = actor == ActorRole.COMPANY ? requireCompany(operatorUserId) : null;
|
|
Offer offer = requireOffer(offerId);
|
|
|
|
// 访问控制(身份一律来自 JWT)
|
|
if (actor == ActorRole.COMPANY) {
|
|
if (!currentCompany.getId().equals(offer.getCompanyId())) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
} else {
|
|
if (!offer.getCandidateId().equals(operatorUserId)) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
}
|
|
|
|
OfferStatus from = OfferStatus.valueOf(offer.getStatus());
|
|
OfferStatus to = OfferStatus.valueOf(req.getStatus());
|
|
|
|
// 幂等:已在目标状态,直接返回,不产生重复历史
|
|
if (from == to) return offer;
|
|
|
|
if (!from.canTransitionTo(to)) {
|
|
throw new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION);
|
|
}
|
|
if (!isRoleAllowed(from, to, actor)) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
// 过期保护:除系统过期外,已到期 Offer 不可再接受/拒绝/洽谈/发送
|
|
if (to != OfferStatus.EXPIRED && offer.getExpireAt() != null
|
|
&& !offer.getExpireAt().isAfter(LocalDateTime.now())) {
|
|
throw new BusinessException(ErrorCode.OFFER_EXPIRED);
|
|
}
|
|
|
|
String fromStatus = offer.getStatus();
|
|
Offer saved = applyTransition(offer, to, operatorUserId, actor.name(), req.getReason(), ctx);
|
|
notifyStatusChange(saved, fromStatus, to);
|
|
auditService.log(operatorUserId, "UPDATE_OFFER_STATUS", "OFFER", saved.getId(),
|
|
"Offer status changed: " + fromStatus + " → " + to.name(), ctx);
|
|
// R01-D4-1:Offer 状态变更影响 ACCEPTED/EXPIRED 履约聚合(INTEGRITY)→ 登记刷新。
|
|
scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), java.time.LocalDateTime.now());
|
|
return saved;
|
|
}
|
|
|
|
/**
|
|
* 应用状态变更并写入历史。
|
|
* 乐观锁:并发下 saveAndFlush 校验 version,冲突时重读——若已在目标状态视为幂等成功。
|
|
*/
|
|
private Offer applyTransition(Offer offer, OfferStatus to, Long operatorId,
|
|
String operatorRole, String reason, AuditContext ctx) {
|
|
String fromStatus = offer.getStatus();
|
|
try {
|
|
offer.setStatus(to.name());
|
|
if (to == OfferStatus.REJECTED || to == OfferStatus.EXPIRED || to == OfferStatus.WITHDRAWN) {
|
|
offer.setActiveKey(null); // 终态释放"唯一有效 Offer"槽位
|
|
}
|
|
offer.setUpdatedBy(operatorId);
|
|
Offer saved = offerRepository.saveAndFlush(offer);
|
|
recordHistory(saved.getId(), fromStatus, to.name(),
|
|
operatorId, operatorRole, reason, ctx);
|
|
// 撤销/过期时同步将 PENDING 还价标记为 EXPIRED
|
|
if (to == OfferStatus.WITHDRAWN || to == OfferStatus.EXPIRED) {
|
|
counterOfferRepository.expirePendingByOfferId(offer.getId());
|
|
}
|
|
return saved;
|
|
} catch (OptimisticLockingFailureException e) {
|
|
// 清除脏上下文,避免提交时对已改脏的 offer 再次 flush 抛出异常
|
|
entityManager.clear();
|
|
Offer fresh = offerRepository.findById(offer.getId())
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND));
|
|
if (fresh.getStatus().equals(to.name())) return fresh; // 并发重复,幂等
|
|
throw new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION,
|
|
"offer was modified concurrently");
|
|
}
|
|
}
|
|
|
|
// ── 手动提醒(企业)───────────────────────────────────────
|
|
|
|
/**
|
|
* 企业手动提醒候选人。
|
|
* 仅未过期 Offer 可提醒;同一 Offer 的 MANUAL 提醒只创建一次(幂等)。
|
|
*/
|
|
@Transactional
|
|
public OfferReminder remind(Long operatorUserId, Long offerId, AuditContext ctx) {
|
|
Company company = requireCompany(operatorUserId);
|
|
Offer offer = requireOffer(offerId);
|
|
if (!company.getId().equals(offer.getCompanyId())) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
if (OfferStatus.EXPIRED.name().equals(offer.getStatus())
|
|
|| (offer.getExpireAt() != null && !offer.getExpireAt().isAfter(LocalDateTime.now()))) {
|
|
throw new BusinessException(ErrorCode.OFFER_EXPIRED);
|
|
}
|
|
if (!OfferStatus.SENT.name().equals(offer.getStatus())
|
|
&& !OfferStatus.NEGOTIATING.name().equals(offer.getStatus())) {
|
|
throw new BusinessException(ErrorCode.OFFER_NOT_REMINDABLE);
|
|
}
|
|
// 幂等:相同提醒不重复创建、不重复通知
|
|
return reminderRepository.findByOfferIdAndReminderType(offerId, OfferReminder.TYPE_MANUAL)
|
|
.orElseGet(() -> createAndSendReminder(offer, OfferReminder.TYPE_MANUAL,
|
|
LocalDateTime.now(), operatorUserId, ctx));
|
|
}
|
|
|
|
/** 创建提醒记录并发送通知(用于定时任务与手动提醒) */
|
|
public OfferReminder createAndSendReminder(Offer offer, String type, LocalDateTime remindAt,
|
|
Long operatorId, AuditContext ctx) {
|
|
OfferReminder reminder = OfferReminder.builder()
|
|
.offerId(offer.getId())
|
|
.reminderType(type)
|
|
.remindAt(remindAt)
|
|
.sentAt(LocalDateTime.now())
|
|
.status(OfferReminder.STATUS_SENT)
|
|
.build();
|
|
try {
|
|
reminder = reminderRepository.save(reminder);
|
|
} catch (org.springframework.dao.DataIntegrityViolationException e) {
|
|
log.info("Duplicate reminder skipped: offer={}, type={}", offer.getId(), type);
|
|
return reminderRepository.findByOfferIdAndReminderType(offer.getId(), type)
|
|
.orElse(reminder);
|
|
}
|
|
try {
|
|
notificationService.publishEvent(offer.getCandidateId(), "OFFER_REMINDER",
|
|
"Offer 到期提醒", "你的 Offer #" + offer.getId() + " 即将到期,请及时处理",
|
|
"OFFER", offer.getId(),
|
|
"offer-remind-" + type.toLowerCase() + "-" + offer.getId());
|
|
} catch (Exception e) {
|
|
log.warn("Reminder notification failed: offer={}, type={}", offer.getId(), type);
|
|
}
|
|
if (operatorId != null && operatorId != SYSTEM_OPERATOR_ID) {
|
|
auditService.log(operatorId, "REMIND_OFFER", "OFFER", offer.getId(),
|
|
"Reminder sent for offer " + offer.getId(), ctx);
|
|
}
|
|
return reminder;
|
|
}
|
|
|
|
// ── 通知(状态变更)───────────────────────────────────────
|
|
|
|
private void notifyStatusChange(Offer offer, String fromStatus, OfferStatus to) {
|
|
String idempotency = "offer-" + to.name().toLowerCase() + "-" + offer.getId();
|
|
if (to == OfferStatus.SENT) {
|
|
// 发送 → 通知候选人
|
|
notificationService.publishEvent(offer.getCandidateId(), "OFFER_SENT",
|
|
"收到 Offer", "你收到来自岗位 #" + offer.getJobId() + " 的 Offer,请及时处理",
|
|
"OFFER", offer.getId(), idempotency);
|
|
return;
|
|
}
|
|
Company company = companyRepository.findById(offer.getCompanyId()).orElse(null);
|
|
if (company == null) return;
|
|
String type;
|
|
String title;
|
|
switch (to) {
|
|
case ACCEPTED -> { type = "OFFER_ACCEPTED"; title = "Offer 已接受"; }
|
|
case REJECTED -> { type = "OFFER_REJECTED"; title = "Offer 已拒绝"; }
|
|
case NEGOTIATING -> { type = "OFFER_NEGOTIATING"; title = "进入洽谈"; }
|
|
case EXPIRED -> { type = "OFFER_EXPIRED"; title = "Offer 已过期"; }
|
|
case WITHDRAWN -> { type = "OFFER_WITHDRAWN"; title = "Offer 已撤销"; }
|
|
default -> { return; }
|
|
}
|
|
notificationService.publishEvent(company.getOwnerUserId(), type, title,
|
|
"Offer #" + offer.getId() + " 状态:已" + to.name(),
|
|
"OFFER", offer.getId(), idempotency);
|
|
}
|
|
|
|
// ── 视图 ──────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> toView(Offer offer) {
|
|
Map<String, Object> v = new HashMap<>();
|
|
v.put("id", offer.getId());
|
|
v.put("applicationId", offer.getApplicationId());
|
|
v.put("jobId", offer.getJobId());
|
|
v.put("companyId", offer.getCompanyId());
|
|
v.put("candidateId", offer.getCandidateId());
|
|
v.put("title", offer.getTitle());
|
|
v.put("salaryMin", offer.getSalaryMin());
|
|
v.put("salaryMax", offer.getSalaryMax());
|
|
v.put("salaryCurrency", offer.getSalaryCurrency());
|
|
v.put("salaryPeriod", offer.getSalaryPeriod());
|
|
v.put("workCity", offer.getWorkCity());
|
|
v.put("employmentType", offer.getEmploymentType());
|
|
v.put("probationMonths", offer.getProbationMonths());
|
|
v.put("benefits", offer.getBenefits());
|
|
v.put("description", offer.getDescription());
|
|
v.put("expireAt", offer.getExpireAt());
|
|
v.put("onboardingDate", offer.getOnboardingDate());
|
|
v.put("baseSalary", offer.getBaseSalary());
|
|
v.put("housingAllowance", offer.getHousingAllowance());
|
|
v.put("annualBonusMonths", offer.getAnnualBonusMonths());
|
|
v.put("otherAllowances", offer.getOtherAllowances());
|
|
v.put("status", offer.getStatus());
|
|
v.put("version", offer.getVersion());
|
|
v.put("createdBy", offer.getCreatedBy());
|
|
v.put("updatedBy", offer.getUpdatedBy());
|
|
v.put("createdAt", offer.getCreatedAt());
|
|
v.put("updatedAt", offer.getUpdatedAt());
|
|
return v;
|
|
}
|
|
|
|
private Map<String, Object> toHistoryView(OfferStatusHistory h) {
|
|
Map<String, Object> v = new HashMap<>();
|
|
v.put("id", h.getId());
|
|
v.put("fromStatus", h.getFromStatus());
|
|
v.put("toStatus", h.getToStatus());
|
|
v.put("operator", h.getOperatorId());
|
|
v.put("operatorRole", h.getOperatorRole());
|
|
v.put("reason", h.getReason());
|
|
v.put("createdAt", h.getCreatedAt());
|
|
v.put("requestId", h.getRequestId());
|
|
return v;
|
|
}
|
|
|
|
// ── 还价(候选人)────────────────────────────────────────
|
|
|
|
/**
|
|
* 候选人发起还价。
|
|
* 前置条件:仅本人 Offer、SENT 或 NEGOTIATING 状态、无 PENDING 还价。
|
|
* SENT 发起还价时,Offer 自动进入 NEGOTIATING。
|
|
*/
|
|
@Transactional
|
|
public OfferCounterOffer createCounterOffer(Long studentUserId, Long offerId,
|
|
CounterOfferCreateRequest req, AuditContext ctx) {
|
|
Offer offer = requireOffer(offerId);
|
|
if (!offer.getCandidateId().equals(studentUserId)) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
String status = offer.getStatus();
|
|
if (!OfferStatus.SENT.name().equals(status) && !OfferStatus.NEGOTIATING.name().equals(status)) {
|
|
throw new BusinessException(ErrorCode.OFFER_NOT_NEGOTIABLE);
|
|
}
|
|
// 检查是否已有 PENDING 还价
|
|
if (counterOfferRepository.findByOfferIdAndActiveKeyNotNull(offerId).isPresent()) {
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXISTS);
|
|
}
|
|
// 过期保护
|
|
if (offer.getExpireAt() != null && !offer.getExpireAt().isAfter(LocalDateTime.now())) {
|
|
throw new BusinessException(ErrorCode.OFFER_EXPIRED);
|
|
}
|
|
validateSalaryRange(req.getProposedSalaryMin(), req.getProposedSalaryMax());
|
|
|
|
// SENT → NEGOTIATING(自动流转)
|
|
if (OfferStatus.SENT.name().equals(status)) {
|
|
OfferStatusRequest sr = new OfferStatusRequest();
|
|
sr.setStatus(OfferStatus.NEGOTIATING.name());
|
|
sr.setReason("Candidate initiated counter-offer");
|
|
transitionStatus(studentUserId, offerId, sr, ctx);
|
|
// 重新加载(transitionStatus 已刷新)
|
|
offer = requireOffer(offerId);
|
|
}
|
|
|
|
OfferCounterOffer co = OfferCounterOffer.builder()
|
|
.offerId(offerId)
|
|
.studentId(studentUserId)
|
|
.proposedSalaryMin(req.getProposedSalaryMin())
|
|
.proposedSalaryMax(req.getProposedSalaryMax())
|
|
.proposedOnboardingDate(req.getProposedOnboardingDate())
|
|
.reason(req.getReason())
|
|
.status(OfferCounterOffer.STATUS_PENDING)
|
|
.activeKey(OfferCounterOffer.ACTIVE_KEY)
|
|
.build();
|
|
try {
|
|
co = counterOfferRepository.save(co);
|
|
} catch (org.springframework.dao.DataIntegrityViolationException e) {
|
|
log.info("Duplicate active counter-offer for offer {}", offerId);
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXISTS);
|
|
}
|
|
auditService.log(studentUserId, "COUNTER_OFFER", "OFFER", offerId,
|
|
"Counter-offer created for offer " + offerId, ctx);
|
|
return co;
|
|
}
|
|
|
|
/** 查看还价列表 */
|
|
public List<OfferCounterOffer> getCounterOffers(Long offerId, Long userId) {
|
|
Offer offer = getOffer(offerId, userId);
|
|
return counterOfferRepository.findByOfferIdOrderByCreatedAtAsc(offerId);
|
|
}
|
|
|
|
/**
|
|
* 企业接受还价:更新 Offer 薪资和入职日期,将 Offer 变为 ACCEPTED。
|
|
*/
|
|
@Transactional
|
|
public OfferCounterOffer acceptCounterOffer(Long companyUserId, Long offerId, Long coId, AuditContext ctx) {
|
|
Company company = requireCompany(companyUserId);
|
|
Offer offer = requireOffer(offerId);
|
|
if (!company.getId().equals(offer.getCompanyId())) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
OfferCounterOffer co = counterOfferRepository.findById(coId)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
|
|
if (!co.getOfferId().equals(offerId)) {
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND);
|
|
}
|
|
if (!OfferCounterOffer.STATUS_PENDING.equals(co.getStatus())) {
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
|
|
}
|
|
if (OfferStatus.WITHDRAWN.name().equals(offer.getStatus())
|
|
|| OfferStatus.EXPIRED.name().equals(offer.getStatus())) {
|
|
// Offer 已撤销/过期,标记还价为 EXPIRED
|
|
co.setStatus(OfferCounterOffer.STATUS_EXPIRED);
|
|
co.setActiveKey(null);
|
|
counterOfferRepository.saveAndFlush(co);
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED);
|
|
}
|
|
|
|
try {
|
|
// 更新 Offer 字段
|
|
if (co.getProposedSalaryMin() != null) offer.setSalaryMin(co.getProposedSalaryMin());
|
|
if (co.getProposedSalaryMax() != null) offer.setSalaryMax(co.getProposedSalaryMax());
|
|
if (co.getProposedOnboardingDate() != null) offer.setOnboardingDate(co.getProposedOnboardingDate());
|
|
offer.setUpdatedBy(companyUserId);
|
|
offerRepository.saveAndFlush(offer);
|
|
|
|
// 更新还价状态
|
|
co.setStatus(OfferCounterOffer.STATUS_ACCEPTED);
|
|
co.setActiveKey(null);
|
|
co = counterOfferRepository.saveAndFlush(co);
|
|
} catch (OptimisticLockingFailureException e) {
|
|
entityManager.clear();
|
|
OfferCounterOffer fresh = counterOfferRepository.findById(coId)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
|
|
if (OfferCounterOffer.STATUS_ACCEPTED.equals(fresh.getStatus())) return fresh;
|
|
if (OfferCounterOffer.STATUS_REJECTED.equals(fresh.getStatus()))
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
|
|
}
|
|
|
|
// 将 Offer 变为 ACCEPTED
|
|
OfferStatusRequest sr = new OfferStatusRequest();
|
|
sr.setStatus(OfferStatus.ACCEPTED.name());
|
|
sr.setReason("Counter-offer accepted by company");
|
|
transitionStatus(companyUserId, offerId, sr, ctx);
|
|
|
|
auditService.log(companyUserId, "ACCEPT_COUNTER_OFFER", "OFFER", offerId,
|
|
"Counter-offer " + coId + " accepted", ctx);
|
|
return co;
|
|
}
|
|
|
|
/**
|
|
* 企业拒绝还价:还价变为 REJECTED,Offer 保持 NEGOTIATING。
|
|
*/
|
|
@Transactional
|
|
public OfferCounterOffer rejectCounterOffer(Long companyUserId, Long offerId, Long coId, AuditContext ctx) {
|
|
Company company = requireCompany(companyUserId);
|
|
Offer offer = requireOffer(offerId);
|
|
if (!company.getId().equals(offer.getCompanyId())) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
OfferCounterOffer co = counterOfferRepository.findById(coId)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
|
|
if (!co.getOfferId().equals(offerId)) {
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND);
|
|
}
|
|
if (!OfferCounterOffer.STATUS_PENDING.equals(co.getStatus())) {
|
|
if (OfferCounterOffer.STATUS_REJECTED.equals(co.getStatus())) return co;
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
|
|
}
|
|
if (OfferStatus.WITHDRAWN.name().equals(offer.getStatus())
|
|
|| OfferStatus.EXPIRED.name().equals(offer.getStatus())) {
|
|
co.setStatus(OfferCounterOffer.STATUS_EXPIRED);
|
|
co.setActiveKey(null);
|
|
counterOfferRepository.saveAndFlush(co);
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED);
|
|
}
|
|
|
|
try {
|
|
co.setStatus(OfferCounterOffer.STATUS_REJECTED);
|
|
co.setActiveKey(null);
|
|
co = counterOfferRepository.saveAndFlush(co);
|
|
} catch (OptimisticLockingFailureException e) {
|
|
entityManager.clear();
|
|
OfferCounterOffer fresh = counterOfferRepository.findById(coId)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
|
|
if (OfferCounterOffer.STATUS_REJECTED.equals(fresh.getStatus())) return fresh;
|
|
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
|
|
}
|
|
|
|
auditService.log(companyUserId, "REJECT_COUNTER_OFFER", "OFFER", offerId,
|
|
"Counter-offer " + coId + " rejected", ctx);
|
|
return co;
|
|
}
|
|
|
|
// ── Offer 比较(候选人)───────────────────────────────────
|
|
|
|
/**
|
|
* 候选人比较多个 Offer。
|
|
* ids 必须是 2-5 个、不可重复、全部为正数;只能比较本人非 DRAFT 的 Offer。
|
|
* 一次 JOIN 查询,返回 Offer 附带企业名称。
|
|
*/
|
|
public List<Map<String, Object>> compareOffers(Long studentUserId, List<Long> ids) {
|
|
if (ids == null || ids.size() < 2 || ids.size() > 5) {
|
|
throw new BusinessException(ErrorCode.OFFER_COMPARE_TOO_MANY);
|
|
}
|
|
Set<Long> idSet = new HashSet<>(ids);
|
|
if (idSet.size() != ids.size()) {
|
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate offer ids");
|
|
}
|
|
for (Long id : ids) {
|
|
if (id == null || id <= 0) {
|
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id");
|
|
}
|
|
}
|
|
|
|
List<Object[]> results = offerRepository.findByIdInAndCandidateIdAndStatusNotDraftWithCompany(idSet, studentUserId);
|
|
if (results.size() != idSet.size()) {
|
|
throw new BusinessException(ErrorCode.OFFER_COMPARE_NOT_OWNER);
|
|
}
|
|
|
|
return results.stream().map(row -> {
|
|
Offer offer = (Offer) row[0];
|
|
String companyName = (String) row[1];
|
|
Map<String, Object> v = toView(offer);
|
|
v.put("companyName", companyName);
|
|
return v;
|
|
}).collect(Collectors.toList());
|
|
}
|
|
|
|
// ── Offer 统计(企业)─────────────────────────────────────
|
|
|
|
/**
|
|
* 企业 Offer 统计看板。
|
|
* months 默认 6,范围 1-24。
|
|
* acceptanceRate = ACCEPTED / (ACCEPTED + REJECTED),无数据时返回 null。
|
|
* avgResponseHours 通过状态历史计算 SENT→ACCEPTED/REJECTED 的平均时间,无数据时返回 null。
|
|
*/
|
|
public Map<String, Object> getStatistics(Long companyUserId, Integer months) {
|
|
Company company = requireCompany(companyUserId);
|
|
if (months == null || months < 1 || months > 24) {
|
|
months = 6;
|
|
}
|
|
|
|
LocalDateTime since = LocalDateTime.now().minusMonths(months);
|
|
List<Object[]> statusCounts = offerRepository.countByCompanyIdAndStatusSince(company.getId(), since);
|
|
|
|
Map<String, Long> byStatus = new LinkedHashMap<>();
|
|
long total = 0;
|
|
long accepted = 0, rejected = 0;
|
|
for (Object[] row : statusCounts) {
|
|
String status = (String) row[0];
|
|
Long count = (Long) row[1];
|
|
byStatus.put(status, count);
|
|
total += count;
|
|
if (OfferStatus.ACCEPTED.name().equals(status)) accepted = count;
|
|
if (OfferStatus.REJECTED.name().equals(status)) rejected = count;
|
|
}
|
|
|
|
// 补全所有状态
|
|
for (OfferStatus s : OfferStatus.values()) {
|
|
byStatus.putIfAbsent(s.name(), 0L);
|
|
}
|
|
|
|
Double acceptanceRate = null;
|
|
long sum = accepted + rejected;
|
|
if (sum > 0) {
|
|
acceptanceRate = (double) accepted / sum;
|
|
}
|
|
|
|
Double avgResponseHours = computeAvgResponseHours(company.getId(), since);
|
|
|
|
Map<String, Object> result = new HashMap<>();
|
|
result.put("totalOffers", total);
|
|
result.put("byStatus", byStatus);
|
|
result.put("acceptanceRate", acceptanceRate);
|
|
result.put("avgResponseHours", avgResponseHours);
|
|
result.put("periodMonths", months);
|
|
return result;
|
|
}
|
|
|
|
private Double computeAvgResponseHours(Long companyId, LocalDateTime since) {
|
|
// 通过 status_history 中 SENT → ACCEPTED/REJECTED 的时间差计算平均响应时间
|
|
List<OfferStatusHistory> histories = historyRepository.findByOfferCompanyAndSince(companyId, since);
|
|
if (histories.isEmpty()) return null;
|
|
|
|
// 按 offer_id 分组,找到每个 Offer 的 SENT 时间和第一个终态时间
|
|
Map<Long, LocalDateTime> sentTimes = new HashMap<>();
|
|
Map<Long, LocalDateTime> terminalTimes = new HashMap<>();
|
|
|
|
for (OfferStatusHistory h : histories) {
|
|
Long oid = h.getOfferId();
|
|
if ("SENT".equals(h.getToStatus()) && h.getCreatedAt() != null) {
|
|
sentTimes.putIfAbsent(oid, h.getCreatedAt());
|
|
}
|
|
if (("ACCEPTED".equals(h.getToStatus()) || "REJECTED".equals(h.getToStatus()))
|
|
&& h.getCreatedAt() != null) {
|
|
terminalTimes.putIfAbsent(oid, h.getCreatedAt());
|
|
}
|
|
}
|
|
|
|
double totalHours = 0;
|
|
int count = 0;
|
|
for (Map.Entry<Long, LocalDateTime> entry : terminalTimes.entrySet()) {
|
|
LocalDateTime sent = sentTimes.get(entry.getKey());
|
|
if (sent != null) {
|
|
totalHours += ChronoUnit.HOURS.between(sent, entry.getValue());
|
|
count++;
|
|
}
|
|
}
|
|
return count > 0 ? totalHours / count : null;
|
|
}
|
|
|
|
// ── 还价视图 ──────────────────────────────────────────────
|
|
|
|
public Map<String, Object> toCounterOfferView(OfferCounterOffer co) {
|
|
Map<String, Object> v = new HashMap<>();
|
|
v.put("id", co.getId());
|
|
v.put("offerId", co.getOfferId());
|
|
v.put("studentId", co.getStudentId());
|
|
v.put("proposedSalaryMin", co.getProposedSalaryMin());
|
|
v.put("proposedSalaryMax", co.getProposedSalaryMax());
|
|
v.put("proposedOnboardingDate", co.getProposedOnboardingDate());
|
|
v.put("reason", co.getReason());
|
|
v.put("status", co.getStatus());
|
|
v.put("version", co.getVersion());
|
|
v.put("createdAt", co.getCreatedAt());
|
|
v.put("updatedAt", co.getUpdatedAt());
|
|
return v;
|
|
}
|
|
|
|
// ── 私有辅助 ──────────────────────────────────────────────
|
|
|
|
private Offer requireOffer(Long offerId) {
|
|
return offerRepository.findById(offerId)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND));
|
|
}
|
|
|
|
private User loadUser(Long userId) {
|
|
return userRepository.findById(userId)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
|
}
|
|
|
|
/** 校验并返回当前用户绑定的企业(COMPANY 角色),未绑定返回 404 */
|
|
private Company requireCompany(Long userId) {
|
|
User user = loadUser(userId);
|
|
if (!hasRole(user, User.Role.COMPANY)) {
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
return VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
|
|
}
|
|
|
|
private void requireVerifiedIfCompany(Long userId) {
|
|
User user = userRepository.findById(userId).orElse(null);
|
|
if (user != null && hasRole(user, User.Role.COMPANY)) requireCompany(userId);
|
|
}
|
|
|
|
private boolean hasRole(User user, User.Role role) {
|
|
return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role);
|
|
}
|
|
|
|
private void assertViewAccess(Offer offer, Long userId) {
|
|
User user = loadUser(userId);
|
|
if (hasRole(user, User.Role.STUDENT) && offer.getCandidateId().equals(userId)) return;
|
|
if (hasRole(user, User.Role.COMPANY)) {
|
|
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
|
|
if (company != null && company.getId().equals(offer.getCompanyId())) {
|
|
VerifiedCompanyGuard.requireVerified(company);
|
|
return;
|
|
}
|
|
}
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
|
|
/** 投递状态允许发 Offer:SUBMITTED / VIEWED / INTERVIEW;终态与撤回不允许 */
|
|
private boolean isApplicationEligible(String applicationStatus) {
|
|
return "SUBMITTED".equals(applicationStatus)
|
|
|| "VIEWED".equals(applicationStatus)
|
|
|| "INTERVIEW".equals(applicationStatus);
|
|
}
|
|
|
|
private void validateSalaryRange(Integer salaryMin, Integer salaryMax) {
|
|
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
|
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "salaryMin must not be greater than salaryMax");
|
|
}
|
|
}
|
|
|
|
private String defaultIfBlank(String value, String fallback) {
|
|
return value == null || value.isBlank() ? fallback : value.trim();
|
|
}
|
|
|
|
private void recordHistory(Long offerId, String fromStatus, String toStatus,
|
|
Long operatorId, String operatorRole, String reason, AuditContext ctx) {
|
|
String requestId = ctx == null ? null : truncate(ctx.getRequestId());
|
|
historyRepository.save(OfferStatusHistory.builder()
|
|
.offerId(offerId)
|
|
.fromStatus(fromStatus)
|
|
.toStatus(toStatus)
|
|
.operatorId(operatorId)
|
|
.operatorRole(operatorRole)
|
|
.reason(reason)
|
|
.requestId(requestId)
|
|
.build());
|
|
}
|
|
|
|
private String truncate(String s) {
|
|
if (s == null) return null;
|
|
return s.length() > 32 ? s.substring(0, 32) : s;
|
|
}
|
|
|
|
/** 校验角色是否可发起某流转(EXPIRED 仅系统任务可触发,WITHDRAWN 仅企业可触发) */
|
|
private boolean isRoleAllowed(OfferStatus from, OfferStatus to, ActorRole actor) {
|
|
return switch (from) {
|
|
case DRAFT -> actor == ActorRole.COMPANY && to == OfferStatus.SENT;
|
|
case SENT -> switch (to) {
|
|
case NEGOTIATING, ACCEPTED, REJECTED -> actor == ActorRole.CANDIDATE;
|
|
case WITHDRAWN -> actor == ActorRole.COMPANY;
|
|
case EXPIRED -> false; // 仅系统任务
|
|
default -> false;
|
|
};
|
|
case NEGOTIATING -> switch (to) {
|
|
case ACCEPTED -> actor == ActorRole.CANDIDATE || actor == ActorRole.COMPANY;
|
|
case REJECTED -> actor == ActorRole.CANDIDATE;
|
|
case WITHDRAWN -> actor == ActorRole.COMPANY;
|
|
case EXPIRED -> false; // 仅系统任务
|
|
default -> false;
|
|
};
|
|
default -> false;
|
|
};
|
|
}
|
|
}
|
|
|