diff --git a/ruoyi-modules/ruoyi-edu/src/main/java/org/dromara/edu/application/service/contract/impl/EduPaymentPlanServiceImpl.java b/ruoyi-modules/ruoyi-edu/src/main/java/org/dromara/edu/application/service/contract/impl/EduPaymentPlanServiceImpl.java new file mode 100644 index 000000000..6476dda5a --- /dev/null +++ b/ruoyi-modules/ruoyi-edu/src/main/java/org/dromara/edu/application/service/contract/impl/EduPaymentPlanServiceImpl.java @@ -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; + +/** + * 合同缴费计划 业务实现 + *

分期合同的应收节点生成 + 收款联动合同账本 + 退费/作废联动作废

+ * + * @author FIGS + */ +@RequiredArgsConstructor +@Service +public class EduPaymentPlanServiceImpl implements IEduPaymentPlanService { + + private final EduContractPaymentPlanMapper paymentPlanMapper; + private final EduContractMapper contractMapper; + private final EduContractLogMapper contractLogMapper; + + /** + * 生成缴费计划(幂等: 先删后建) + *

金额: 首期吸收尾差, 保证各期之和精确等于 totalAmount; + * 日期: 首期=签约日, 第N期=签约日+(N-1)*30天

+ */ + @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() + .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 selectPlanList(Long contractId) { + return paymentPlanMapper.selectVoList(new LambdaQueryWrapper() + .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); + } + + /** + * 收款确认: 待收 → 已收, 联动合同账本 + *

1. 置该期已收, 记 paidAmount/paidDate/paymentMethod; + * 2. 重算合同 paidAmount = SUM(已收期 paidAmount); + * 3. 重算 paymentStatus: 全额=已支付(2), 否则部分支付(1); + * 4. 写合同流水(action=RECEIVE_PAYMENT).

+ */ + @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 all = paymentPlanMapper.selectList( + new LambdaQueryWrapper() + .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; + } + + /** + * 批量作废未收期次(合同退费/作废联动) + *

仅把"待收"期置"已作废", "已收"期保留作为历史收款记录

+ */ + @Override + @Transactional(rollbackFor = Exception.class) + public int voidPendingPlans(Long contractId) { + List pending = paymentPlanMapper.selectList( + new LambdaQueryWrapper() + .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()); + } + +}