This commit is contained in:
wuweihua
2026-10-28 21:42:55 +08:00
17 changed files with 74 additions and 701 deletions
@@ -246,10 +246,8 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
String receiptType;
if (newAllocated.compareTo(BigDecimal.ZERO) == 0) {
receiptType = CustomerReceipt.TYPE_PREPAY;
} else if (newAllocated.compareTo(base) >= 0) {
receiptType = CustomerReceipt.TYPE_REPAY;
} else {
receiptType = CustomerReceipt.TYPE_MIXED;
receiptType = CustomerReceipt.TYPE_REPAY;
}
CustomerReceipt update = new CustomerReceipt();
@@ -20,7 +20,7 @@ import com.bms.customer.customermoney.vo.CustomerAccountStatusVO;
* <li>收款审核入账/退款:{@link #refundBalance} 加余额</li>
* </ul>
*
* <p>注意(核销口径):应收账单的余额核销与反核销(含收款审核联动核销)<b>不调用本接口</b>,
* <p>注意(核销口径):应收账单的余额核销与反核销(含历史收款审核联动核销)<b>不调用本接口</b>,
* 只更新账单已核销/未核销金额与状态,客户余额与授信额度均不发生变动。</p>
*
* @author bms
@@ -30,10 +30,8 @@ public class CustomerReceipt extends BaseEntity
/** 收款类型:预充值 */
public static final String TYPE_PREPAY = "PREPAY";
/** 收款类型:还款核销 */
/** 收款类型:还款 */
public static final String TYPE_REPAY = "REPAY";
/** 收款类型:混合 */
public static final String TYPE_MIXED = "MIXED";
/** 汇率来源:系统默认(本币CNY 1:1) */
public static final String RATE_SOURCE_SYSTEM = "SYSTEM";
@@ -60,7 +58,7 @@ public class CustomerReceipt extends BaseEntity
/** 客户账户ID(预留) */
private Long accountId;
/** 收款类型:PREPAY预充值/REPAY还款核销/MIXED混合 */
/** 收款类型:PREPAY预充值/REPAY还款 */
private String receiptType;
/** 收款金额 */
@@ -131,7 +129,7 @@ public class CustomerReceipt extends BaseEntity
/** 收款附件列表(存 ctr_customer_attachment,attachment_type='3',biz_id 关联本单) */
private List<BmsCustomerAttachment> attachmentList;
/** 核销账单计划(登记时选择,审核通过后一并核销) */
/** 历史收款单关联的核销计划;新登记收款不再创建计划 */
private List<ReceiptWriteoffPlan> writeoffPlans;
/** 收款附件数量(列表查询统计) */
@@ -10,9 +10,9 @@ import lombok.EqualsAndHashCode;
/**
* 收款单核销账单计划 ctr_receipt_writeoff_plan
*
* <p>登记收款时指定待核销的应收账单(PENDING);收款审核通过后在同一事务内逐张执行核销,
* 回写核销单ID并置 EFFECTIVE;待审核收款单被取消则置 CANCELLED;
* 已生效的核销单后续在“余额核销”页被反核销则置 REVERSED(核销/反核销均不改动客户余额与额度)。</p>
* <p>兼容历史收款单关联的账单核销记录。新收款不再创建联动核销计划;
* 历史待审核计划在收款确认或取消时置 CANCELLED;已执行记录保留用于审计,
* 已生效核销单在“余额核销”页反核销后置 REVERSED。</p>
*
* @author bms
* @date 2026-10-08
@@ -23,13 +23,13 @@ public class ReceiptWriteoffPlan extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 状态:待执行(收款单待审核) */
/** 状态:历史待审核计划 */
public static final String STATUS_PENDING = "PENDING";
/** 状态:已核销(收款审核通过并完成账单核销) */
/** 状态:已核销(历史收款审核联动完成) */
public static final String STATUS_EFFECTIVE = "EFFECTIVE";
/** 状态:已作废(待审核收款单被取消,计划未执行) */
/** 状态:已作废(收款单取消或确认时清理历史待执行计划) */
public static final String STATUS_CANCELLED = "CANCELLED";
/** 状态:已反核销(审核联动核销后,核销单在余额核销页被反核销) */
/** 状态:已反核销(关联的核销单在余额核销页被反核销) */
public static final String STATUS_REVERSED = "REVERSED";
/** 主键 */
@@ -62,7 +62,7 @@ public interface CustomerMoneyMapper
/** 新增收款单 */
int insertReceipt(CustomerReceipt receipt);
/** 审核通过:PENDING -> CONFIRMED(带状态+乐观锁条件) */
/** 审核通过:PENDING -> CONFIRMED,回写未分配金额(带状态+乐观锁条件) */
int confirmReceipt(CustomerReceipt receipt);
/** 取消收款单:PENDING -> CANCELLED */
@@ -5,25 +5,17 @@ import org.apache.ibatis.annotations.Param;
import com.bms.customer.customermoney.domain.ReceiptWriteoffPlan;
/**
* 收款单核销账单计划 Mapper
* 历史收款单核销关联 Mapper
*
* @author bms
* @date 2026-10-08
*/
public interface ReceiptWriteoffPlanMapper
{
/** 新增一条核销计划 */
int insertPlan(ReceiptWriteoffPlan plan);
/** 按收款单查询全部计划(关联账单展示字段,按计划ID升序) */
List<ReceiptWriteoffPlan> selectByReceiptId(@Param("receiptId") Long receiptId);
/** 执行成功回写:PENDING -> EFFECTIVE,记录核销单ID与核销日期 */
int updateEffective(@Param("planId") Long planId,
@Param("writeoffId") Long writeoffId,
@Param("updateBy") String updateBy);
/** 收款单取消:其全部 PENDING 计划置 CANCELLED */
/** 收款确认或取消:其历史 PENDING 计划置 CANCELLED */
int cancelPendingByReceiptId(@Param("receiptId") Long receiptId,
@Param("updateBy") String updateBy);
@@ -5,11 +5,8 @@ import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -18,13 +15,8 @@ import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.SecurityUtils;
import com.bms.ar.domain.ArBalanceWriteoff;
import com.bms.ar.domain.ArBankAccount;
import com.bms.ar.domain.ArBill;
import com.bms.ar.enums.ArBillStatus;
import com.bms.ar.mapper.ArBankAccountMapper;
import com.bms.ar.mapper.ArBillMapper;
import com.bms.ar.service.IArBalanceWriteoffService;
import com.bms.customer.customerinfo.domain.BmsCustomer;
import com.bms.customer.customerinfo.domain.BmsCustomerAttachment;
import com.bms.customer.customerinfo.mapper.BmsCustomerMapper;
@@ -36,7 +28,6 @@ import com.bms.customer.customermoney.domain.CustomerBalanceFlow;
import com.bms.customer.customermoney.domain.CustomerCreditApply;
import com.bms.customer.customermoney.domain.CustomerCreditFlow;
import com.bms.customer.customermoney.domain.CustomerReceipt;
import com.bms.customer.customermoney.domain.ReceiptWriteoffPlan;
import com.bms.customer.customermoney.mapper.CustomerCreditApplyMapper;
import com.bms.customer.customermoney.mapper.CustomerMoneyMapper;
import com.bms.customer.customermoney.mapper.ReceiptWriteoffPlanMapper;
@@ -89,18 +80,10 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
@Autowired
private ArBankAccountMapper arBankAccountMapper;
/** 应收账单:登记收款选择核销账单时校验账单状态/未核销额 */
@Autowired
private ArBillMapper arBillMapper;
/** 收款核销计划 */
/** 历史收款核销计划:仅用于查询及处理旧计划 */
@Autowired
private ReceiptWriteoffPlanMapper receiptWriteoffPlanMapper;
/** 余额核销:收款审核通过事务内逐张核销账单 */
@Autowired
private IArBalanceWriteoffService balanceWriteoffService;
@Override
@Transactional(rollbackFor = Exception.class)
public List<CustomerAccount> selectAccountList(Long customerId)
@@ -157,7 +140,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
{
throw new ServiceException("收款金额必须大于0");
}
// 统一金额口径:入口即规范为两位小数,后续本位币、核销合计、余额分配均基于该值计算
// 统一金额口径:入口即规范为两位小数,后续本位币金额与客户余额入账均基于该值计算
receipt.setAmount(receipt.getAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
if (!StringUtils.hasText(receipt.getReceiptType()))
{
@@ -256,24 +239,12 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
BigDecimal baseAmount = receipt.getAmount().multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
receipt.setBaseAmount(baseAmount);
// 核销账单计划校验:当前账单均为人民币(CNY)账单,计划金额即本位币 CNY 金额,
// 上限为收款按汇率折算后的本位币金额 baseAmount;核销本身不增减客户余额。
List<ReceiptWriteoffPlan> plans = validateWriteoffPlans(receipt);
BigDecimal allocatedBase = plans.stream()
.map(ReceiptWriteoffPlan::getWriteoffAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 全额分配=还款核销,部分分配=混合,不选账单=预充值/保持所选类型(本位币口径比较)
if (!plans.isEmpty())
{
receipt.setReceiptType(allocatedBase.compareTo(baseAmount) == 0
? CustomerReceipt.TYPE_REPAY : CustomerReceipt.TYPE_MIXED);
}
receipt.setReceiptNo(generateNo("SK"));
receipt.setStatus(CustomerReceipt.STATUS_PENDING);
// 核销金额即本位币 CNY,无需再乘汇率
receipt.setAllocatedAmount(allocatedBase);
receipt.setUnallocatedAmount(baseAmount.subtract(allocatedBase));
// 登记收款只记录资金,不接受账单核销计划;新收款先全部作为未分配金额入账。
receipt.setAllocatedAmount(BigDecimal.ZERO.setScale(MONEY_SCALE, RoundingMode.UNNECESSARY));
receipt.setUnallocatedAmount(baseAmount);
receipt.setWriteoffPlans(null);
if (!StringUtils.hasText(receipt.getInvoiceStatus()))
{
receipt.setInvoiceStatus("NONE");
@@ -281,146 +252,11 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
receipt.setVersion(0);
customerMoneyMapper.insertReceipt(receipt);
// 核销计划落 PENDING,审核通过事务内再逐张执行
String operator = receipt.getCreateBy();
for (ReceiptWriteoffPlan plan : plans)
{
plan.setReceiptId(receipt.getId());
plan.setStatus(ReceiptWriteoffPlan.STATUS_PENDING);
plan.setCreateBy(operator);
receiptWriteoffPlanMapper.insertPlan(plan);
}
// 收款附件:复用客户附件表,业务模块挂 customer_receipt,biz_id 关联收款单
saveReceiptAttachments(receipt);
return 1;
}
/**
* 校验登记收款携带的核销账单计划并规范化。
* 口径(暂不支持多币种账单):
* 账单必须属于当前客户、账单币种为人民币 CNY、状态可核销(已确认/部分核销);
* 每张本次核销额(CNY)大于0且不超过账单未核销额;
* 合计不超过收款按汇率折算后的本位币金额 baseAmount(CNY)。
* 审核时会再次锁账单强校验,防止登记后账单被别处抢先核销。
*/
private List<ReceiptWriteoffPlan> validateWriteoffPlans(CustomerReceipt receipt)
{
List<ReceiptWriteoffPlan> raw = receipt.getWriteoffPlans();
if (CollectionUtils.isEmpty(raw))
{
return new ArrayList<>();
}
BigDecimal baseAmount = receipt.getBaseAmount() != null
? receipt.getBaseAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)
: receipt.getAmount().multiply(receipt.getExchangeRate()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
List<ReceiptWriteoffPlan> plans = new ArrayList<>();
Set<Long> billIds = new HashSet<>();
BigDecimal total = BigDecimal.ZERO;
for (ReceiptWriteoffPlan item : raw)
{
if (item == null || item.getArBillId() == null)
{
continue;
}
BigDecimal amount = item.getWriteoffAmount();
if (amount == null)
{
throw new ServiceException("请填写每张账单的本次核销金额");
}
amount = amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (amount.compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("核销金额必须大于0");
}
if (!billIds.add(item.getArBillId()))
{
throw new ServiceException("同一账单不能重复选择,请合并为一条并填写合计金额");
}
ArBill bill = arBillMapper.selectArBillById(item.getArBillId());
if (bill == null)
{
throw new ServiceException("所选应收账单不存在,请刷新后重新选择");
}
if (!receipt.getCustomerId().equals(bill.getCustomerId()))
{
throw new ServiceException("账单【" + bill.getBillNo() + "】不属于当前客户");
}
// 现阶段仅支持人民币账单核销;外币账单暂不在此入口处理
String billCurrency = StringUtils.hasText(bill.getCurrency())
? bill.getCurrency().trim().toUpperCase() : BASE_CURRENCY;
if (!BASE_CURRENCY.equals(billCurrency))
{
throw new ServiceException("账单【" + bill.getBillNo() + "】币种为 " + billCurrency
+ ",当前仅支持人民币(CNY)账单核销");
}
ArBillStatus billStatus = ArBillStatus.of(bill.getStatus());
if (billStatus == null || !billStatus.canWriteOff())
{
throw new ServiceException("账单【" + bill.getBillNo() + "】当前状态不允许核销:"
+ (billStatus == null ? bill.getStatus() : billStatus.getLabel()));
}
BigDecimal unwritten = bill.getUnwrittenAmount() == null
? BigDecimal.ZERO : bill.getUnwrittenAmount();
if (amount.compareTo(unwritten) > 0)
{
throw new ServiceException("账单【" + bill.getBillNo() + "】本次核销金额 "
+ amount.toPlainString() + " 超过其未核销金额 " + unwritten.toPlainString());
}
total = total.add(amount);
if (total.compareTo(baseAmount) > 0)
{
throw new ServiceException("核销金额合计 " + total.toPlainString()
+ " CNY 超过本收款单折算本位币金额 " + baseAmount.toPlainString() + " CNY");
}
ReceiptWriteoffPlan plan = new ReceiptWriteoffPlan();
plan.setArBillId(bill.getArBillId());
plan.setBillNo(bill.getBillNo());
plan.setCustomerId(bill.getCustomerId());
plan.setCurrency(BASE_CURRENCY);
plan.setWriteoffAmount(amount);
plan.setRemark(StringUtils.hasText(item.getRemark()) ? item.getRemark().trim() : null);
plans.add(plan);
}
return plans;
}
/**
* 收款审核通过后执行核销计划:同一事务内逐张锁账单核销,
* 任一账单已被别处抢先核销导致超额时,整笔审核回滚(含余额入账),不产生半截数据。
*/
private void executeReceiptWriteoffPlans(CustomerReceipt receipt, String operator)
{
List<ReceiptWriteoffPlan> plans = receiptWriteoffPlanMapper.selectByReceiptId(receipt.getId());
if (CollectionUtils.isEmpty(plans))
{
return;
}
Date writeoffDate = new Date();
for (ReceiptWriteoffPlan plan : plans)
{
if (!ReceiptWriteoffPlan.STATUS_PENDING.equals(plan.getStatus()))
{
continue;
}
ArBalanceWriteoff cmd = new ArBalanceWriteoff();
cmd.setArBillId(plan.getArBillId());
cmd.setCustomerId(plan.getCustomerId());
cmd.setReceiptId(receipt.getId());
cmd.setWriteoffAmount(plan.getWriteoffAmount());
cmd.setWriteoffDate(writeoffDate);
cmd.setRemark("收款单审核联动核销:" + receipt.getReceiptNo());
cmd.setCreateBy(operator);
// writeoff 内部锁账单行、复核状态与未核销额,冲突时抛异常由外层事务统一回滚
ArBalanceWriteoff done = balanceWriteoffService.writeoff(cmd);
if (receiptWriteoffPlanMapper.updateEffective(plan.getPlanId(), done.getWriteoffId(), operator) != 1)
{
throw new ServiceException("收款核销计划状态已变化,请刷新后重试");
}
}
}
/**
* 构造汇率表来源说明:汇率来源(中国银行/央行等)+ 命中路径(正向/反向取倒数/经CNY交叉)。
* resolveRate 返回对象的 remark 带 __RATE_PATH__ 前缀标记命中路径。
@@ -511,14 +347,16 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
receipt.setConfirmedAt(new Date());
receipt.setConfirmedBy(userId);
receipt.setUpdateBy(username);
receipt.setAllocatedAmount(BigDecimal.ZERO.setScale(MONEY_SCALE, RoundingMode.UNNECESSARY));
receipt.setUnallocatedAmount(baseAmount.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
// 旧版本待审核收款可能已有未执行计划;转为取消并恢复为未分配金额,不再联动核销。
receiptWriteoffPlanMapper.cancelPendingByReceiptId(id, username);
int receiptRows = customerMoneyMapper.confirmReceipt(receipt);
if (receiptRows == 0)
{
throw new ServiceException("收款单状态已变更,请刷新后重试");
}
// 3. 登记时选择的账单在同一事务内一并核销;任一账单核销失败则整笔审核回滚
executeReceiptWriteoffPlans(receipt, username);
return receiptRows;
}
@@ -537,7 +375,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
}
String operator = SecurityUtils.getUsername();
int rows = customerMoneyMapper.cancelReceipt(id, operator);
// 尚未执行的核销计划一并作废
// 旧版本待确认计划一并作废
receiptWriteoffPlanMapper.cancelPendingByReceiptId(id, operator);
return rows;
}
@@ -83,8 +83,8 @@ public class CustomerReceiptExportVO implements Serializable
private static String receiptTypeLabel(String v)
{
if ("PREPAY".equals(v)) return "预充值";
if ("REPAY".equals(v)) return "还款核销";
if ("MIXED".equals(v)) return "混合";
if ("REPAY".equals(v)) return "还款";
if ("MIXED".equals(v)) return "-";
return v;
}
@@ -385,6 +385,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="confirmReceipt" parameterType="com.bms.customer.customermoney.domain.CustomerReceipt">
update ctr_customer_receipt
set status = 'CONFIRMED',
allocated_amount = #{allocatedAmount},
unallocated_amount = #{unallocatedAmount},
confirmed_at = #{confirmedAt},
confirmed_by = #{confirmedBy},
version = version + 1,
@@ -395,13 +397,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="cancelReceipt">
update ctr_customer_receipt
set status = 'CANCELLED',
update_by = #{updateBy},
update_time = now()
set status = 'CANCELLED',
allocated_amount = 0.00,
unallocated_amount = ifnull(base_amount, amount),
update_by = #{updateBy},
update_time = now()
where id = #{id} and status = 'PENDING' and del_flag = '0'
</update>
<!-- 反核销联动:仅回写分配口径金额与收款类型,客户余额/额度不在此变动 -->
<!-- 兼容历史反核销联动:回写分配口径金额与收款类型,客户余额/额度不在此变动 -->
<update id="updateReceiptAllocation" parameterType="com.bms.customer.customermoney.domain.CustomerReceipt">
update ctr_customer_receipt
set allocated_amount = #{allocatedAmount},
@@ -39,32 +39,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
left join ar_bill b on b.ar_bill_id = p.ar_bill_id
</sql>
<insert id="insertPlan" parameterType="com.bms.customer.customermoney.domain.ReceiptWriteoffPlan"
useGeneratedKeys="true" keyProperty="planId">
insert into ctr_receipt_writeoff_plan (
receipt_id, ar_bill_id, bill_no, customer_id, currency, writeoff_amount,
status, writeoff_id, remark, create_by, create_time, del_flag
) values (
#{receiptId}, #{arBillId}, #{billNo}, #{customerId}, #{currency}, #{writeoffAmount},
#{status}, #{writeoffId}, #{remark}, #{createBy}, sysdate(), '0'
)
</insert>
<select id="selectByReceiptId" resultMap="ReceiptWriteoffPlanResult">
<include refid="selectColumns"/>
where p.receipt_id = #{receiptId} and p.del_flag = '0'
order by p.plan_id asc
</select>
<update id="updateEffective">
update ctr_receipt_writeoff_plan
set status = 'EFFECTIVE',
writeoff_id = #{writeoffId},
update_by = #{updateBy},
update_time = sysdate()
where plan_id = #{planId} and status = 'PENDING' and del_flag = '0'
</update>
<update id="cancelPendingByReceiptId">
update ctr_receipt_writeoff_plan
set status = 'CANCELLED',