feat(edu): 缴费计划 Service 实现-生成/查询/改/收款联动/作废

This commit is contained in:
liuhuayong 2026-06-27 10:17:26 +08:00
parent b52ab2eb40
commit abfb425b4f

View File

@ -0,0 +1,212 @@
package org.dromara.edu.application.service.contract.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.edu.application.dto.EduPaymentPlanBo;
import org.dromara.edu.application.dto.PaymentReceiveBo;
import org.dromara.edu.application.service.contract.IEduPaymentPlanService;
import org.dromara.edu.common.constant.EduConstants;
import org.dromara.edu.common.exception.EduBusinessException;
import org.dromara.edu.domain.model.contract.EduContract;
import org.dromara.edu.domain.model.contract.EduContractLog;
import org.dromara.edu.domain.model.contract.EduContractPaymentPlan;
import org.dromara.edu.infrastructure.persistence.mapper.EduContractLogMapper;
import org.dromara.edu.infrastructure.persistence.mapper.EduContractMapper;
import org.dromara.edu.infrastructure.persistence.mapper.EduContractPaymentPlanMapper;
import org.dromara.edu.interfaces.vo.EduContractPaymentPlanVo;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
/**
* 合同缴费计划 业务实现
* <p>分期合同的应收节点生成 + 收款联动合同账本 + 退费/作废联动作废</p>
*
* @author FIGS
*/
@RequiredArgsConstructor
@Service
public class EduPaymentPlanServiceImpl implements IEduPaymentPlanService {
private final EduContractPaymentPlanMapper paymentPlanMapper;
private final EduContractMapper contractMapper;
private final EduContractLogMapper contractLogMapper;
/**
* 生成缴费计划(幂等: 先删后建)
* <p>金额: 首期吸收尾差, 保证各期之和精确等于 totalAmount;
* 日期: 首期=签约日, 第N期=签约日+(N-1)*30天</p>
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void generatePaymentPlan(Long contractId, int installmentCount) {
EduContract contract = contractMapper.selectById(contractId);
if (contract == null) {
throw new EduBusinessException("CONTRACT_NOT_FOUND", "合同不存在");
}
if (installmentCount != 2 && installmentCount != 3) {
throw new EduBusinessException("INSTALLMENT_COUNT_INVALID", "分期数必须为 2 或 3");
}
// 幂等: 先删后建
paymentPlanMapper.delete(new LambdaQueryWrapper<EduContractPaymentPlan>()
.eq(EduContractPaymentPlan::getContractId, contractId));
BigDecimal totalAmount = nz(contract.getTotalAmount());
// base = totalAmount / N, 第2~N期取 base, 第1期吸收尾差
BigDecimal base = totalAmount.divide(BigDecimal.valueOf(installmentCount), 2, RoundingMode.HALF_UP);
BigDecimal firstAmount = totalAmount.subtract(base.multiply(BigDecimal.valueOf(installmentCount - 1)));
LocalDate signLocal = toLocalDate(contract.getSignDate());
for (int i = 1; i <= installmentCount; i++) {
EduContractPaymentPlan plan = new EduContractPaymentPlan();
plan.setContractId(contractId);
plan.setInstallmentNo(i);
plan.setPlanAmount(i == 1 ? firstAmount : base);
plan.setPlanDate(toDate(signLocal.plus(30L * (i - 1), ChronoUnit.DAYS)));
plan.setPaidAmount(BigDecimal.ZERO);
plan.setStatus(EduConstants.PaymentPlanStatus.PENDING);
paymentPlanMapper.insert(plan);
}
}
/**
* 查询合同的计划列表(按期次升序)
*/
@Override
public List<EduContractPaymentPlanVo> selectPlanList(Long contractId) {
return paymentPlanMapper.selectVoList(new LambdaQueryWrapper<EduContractPaymentPlan>()
.eq(EduContractPaymentPlan::getContractId, contractId)
.orderByAsc(EduContractPaymentPlan::getInstallmentNo));
}
/**
* 修改单期计划(仅待收态可改应收金额/应付日期)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int updatePlan(EduPaymentPlanBo bo) {
EduContractPaymentPlan exist = paymentPlanMapper.selectById(bo.getId());
if (exist == null) {
throw new EduBusinessException("PLAN_NOT_FOUND", "缴费计划不存在");
}
if (!EduConstants.PaymentPlanStatus.PENDING.equals(exist.getStatus())) {
throw new EduBusinessException("PLAN_STATUS_NOT_EDITABLE", "仅待收态计划可修改");
}
EduContractPaymentPlan update = new EduContractPaymentPlan();
update.setId(bo.getId());
if (bo.getPlanAmount() != null) {
update.setPlanAmount(bo.getPlanAmount());
}
if (bo.getPlanDate() != null) {
update.setPlanDate(bo.getPlanDate());
}
update.setRemark(bo.getRemark());
return paymentPlanMapper.updateById(update);
}
/**
* 收款确认: 待收 已收, 联动合同账本
* <p>1. 置该期已收, paidAmount/paidDate/paymentMethod;
* 2. 重算合同 paidAmount = SUM(已收期 paidAmount);
* 3. 重算 paymentStatus: 全额=已支付(2), 否则部分支付(1);
* 4. 写合同流水(action=RECEIVE_PAYMENT).</p>
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int receivePayment(PaymentReceiveBo bo) {
EduContractPaymentPlan plan = paymentPlanMapper.selectById(bo.getId());
if (plan == null) {
throw new EduBusinessException("PLAN_NOT_FOUND", "缴费计划不存在");
}
if (!EduConstants.PaymentPlanStatus.PENDING.equals(plan.getStatus())) {
throw new EduBusinessException("PLAN_STATUS_NOT_RECEIVABLE", "仅待收态计划可收款");
}
// 1. 更新该期
EduContractPaymentPlan update = new EduContractPaymentPlan();
update.setId(plan.getId());
update.setStatus(EduConstants.PaymentPlanStatus.RECEIVED);
update.setPaidAmount(bo.getPaidAmount());
update.setPaymentMethod(bo.getPaymentMethod());
update.setPaidDate(bo.getPaidDate() != null ? bo.getPaidDate() : new Date());
update.setRemark(bo.getRemark());
int rows = paymentPlanMapper.updateById(update);
// 2. 重算合同实收: SUM(已收期 paidAmount), 本期刚置为已收, 库里已为最新值
List<EduContractPaymentPlan> all = paymentPlanMapper.selectList(
new LambdaQueryWrapper<EduContractPaymentPlan>()
.eq(EduContractPaymentPlan::getContractId, plan.getContractId()));
BigDecimal receivedSum = all.stream()
.filter(p -> EduConstants.PaymentPlanStatus.RECEIVED.equals(p.getStatus()))
.map(p -> nz(p.getPaidAmount()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 3. 重算 paymentStatus 并回写合同
EduContract contract = contractMapper.selectById(plan.getContractId());
BigDecimal total = nz(contract.getTotalAmount());
String paymentStatus = receivedSum.compareTo(total) >= 0 ? "2" : "1";
EduContract contractUpd = new EduContract();
contractUpd.setId(contract.getId());
contractUpd.setPaidAmount(receivedSum);
contractUpd.setPaymentStatus(paymentStatus);
contractMapper.updateById(contractUpd);
// 4. 写合同流水
EduContractLog log = new EduContractLog();
log.setContractId(plan.getContractId());
log.setAction(EduConstants.ContractAction.RECEIVE_PAYMENT);
log.setAmount(bo.getPaidAmount());
log.setOperatorId(LoginHelper.getUserId());
log.setOperatorName(LoginHelper.getUsername());
log.setReason("" + plan.getInstallmentNo() + "期收款, 金额 " + bo.getPaidAmount()
+ (bo.getPaymentMethod() != null ? ", 方式 " + bo.getPaymentMethod() : ""));
log.setLogTime(new Date());
contractLogMapper.insert(log);
return rows;
}
/**
* 批量作废未收期次(合同退费/作废联动)
* <p>仅把"待收"期置"已作废", "已收"期保留作为历史收款记录</p>
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int voidPendingPlans(Long contractId) {
List<EduContractPaymentPlan> pending = paymentPlanMapper.selectList(
new LambdaQueryWrapper<EduContractPaymentPlan>()
.eq(EduContractPaymentPlan::getContractId, contractId)
.eq(EduContractPaymentPlan::getStatus, EduConstants.PaymentPlanStatus.PENDING));
int rows = 0;
for (EduContractPaymentPlan p : pending) {
EduContractPaymentPlan upd = new EduContractPaymentPlan();
upd.setId(p.getId());
upd.setStatus(EduConstants.PaymentPlanStatus.VOIDED);
rows += paymentPlanMapper.updateById(upd);
}
return rows;
}
// ------------------------------------------------------------------
// 私有辅助
// ------------------------------------------------------------------
private BigDecimal nz(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private LocalDate toLocalDate(Date date) {
return date == null ? LocalDate.now() : date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
private Date toDate(LocalDate localDate) {
return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
}