客户余额记录

This commit is contained in:
luojiaquan
2026-09-14 11:51:54 +08:00
parent 4cc20386d9
commit 18f01d1b33
16 changed files with 2349 additions and 43 deletions
@@ -47,6 +47,9 @@ public class BmsCustomerAttachment extends BaseEntity
@Excel(name = "附件类型", readConverterExp = "0=附件资料,1=客户Logo,2=合同文件,3=其他")
private String attachmentType;
/** 关联业务ID(如收款单ID,attachment_type=3 时使用) */
private Long bizId;
/** 是否Logo(0否 1是) */
private String isLogo;
@@ -0,0 +1,52 @@
package com.ruoyi.customer.customermoney.domain;
import java.math.BigDecimal;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户余额账户 customer_account
*
* @author ruoyi
* @date 2026-09-11
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerAccount extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 主键 */
private Long id;
/** 客户ID */
private Long customerId;
/** 币种,如 CNY/USD */
private String currency;
/** 账户余额 */
private BigDecimal balance;
/** 冻结余额 */
private BigDecimal frozenBalance;
/** 总额度 */
private BigDecimal totalCredit;
/** 额外额度 */
private BigDecimal extraCredit;
/** 已用额度 */
private BigDecimal usedCredit;
/** 税金 */
private BigDecimal tax;
/** 乐观锁版本号 */
private Integer version;
/** 删除标志(0存在 1删除) */
private String delFlag;
}
@@ -0,0 +1,80 @@
package com.ruoyi.customer.customermoney.domain;
import java.math.BigDecimal;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户余额流水 customer_balance_flow
*
* @author ruoyi
* @date 2026-09-11
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerBalanceFlow extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 业务类型:收款 */
public static final String BIZ_RECEIPT = "RECEIPT";
/** 业务类型:账单支付 */
public static final String BIZ_BILL_PAY = "BILL_PAY";
/** 业务类型:退款 */
public static final String BIZ_REFUND = "REFUND";
/** 业务类型:手工调整 */
public static final String BIZ_ADJUST = "ADJUST";
/** 业务类型:冻结 */
public static final String BIZ_FREEZE = "FREEZE";
/** 业务类型:解冻 */
public static final String BIZ_UNFREEZE = "UNFREEZE";
/** 主键 */
private Long id;
/** 流水号 */
private String flowNo;
/** 客户ID */
private Long customerId;
/** 客户账户ID */
private Long accountId;
/** 币种 */
private String currency;
/** 业务类型:RECEIPT/BILL_PAY/REFUND/ADJUST/FREEZE/UNFREEZE */
private String bizType;
/** 关联业务ID */
private Long bizId;
/** 关联业务单号 */
private String bizNo;
/** 变动方向:1增加,-1减少 */
private Integer direction;
/** 变动金额 */
private BigDecimal amount;
/** 变动前余额 */
private BigDecimal beforeBalance;
/** 变动后余额 */
private BigDecimal afterBalance;
/** 变动前冻结余额 */
private BigDecimal beforeFrozenBalance;
/** 变动后冻结余额 */
private BigDecimal afterFrozenBalance;
/** 操作人ID */
private Long operatorId;
/** 删除标志(0存在 1删除) */
private String delFlag;
}
@@ -0,0 +1,99 @@
package com.ruoyi.customer.customermoney.domain;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import com.ruoyi.customer.customerinfo.domain.BmsCustomerAttachment;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户收款 customer_receipt
*
* @author ruoyi
* @date 2026-09-11
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerReceipt extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 状态:待确认 */
public static final String STATUS_PENDING = "PENDING";
/** 状态:已确认 */
public static final String STATUS_CONFIRMED = "CONFIRMED";
/** 状态:已取消 */
public static final String STATUS_CANCELLED = "CANCELLED";
/** 收款类型:预充值 */
public static final String TYPE_PREPAY = "PREPAY";
/** 收款类型:还款核销 */
public static final String TYPE_REPAY = "REPAY";
/** 收款类型:混合 */
public static final String TYPE_MIXED = "MIXED";
/** 主键 */
private Long id;
/** 收款单号 */
private String receiptNo;
/** 客户ID */
private Long customerId;
/** 客户账户ID(预留) */
private Long accountId;
/** 收款类型:PREPAY预充值/REPAY还款核销/MIXED混合 */
private String receiptType;
/** 收款金额 */
private BigDecimal amount;
/** 已核销金额 */
private BigDecimal allocatedAmount;
/** 待核销金额 */
private BigDecimal unallocatedAmount;
/** 币种 */
private String currency;
/** 收款方式:BANK_TRANSFER/ALIPAY/WECHAT/CASH/OTHER */
private String payMethod;
/** 银行流水号/支付平台流水号 */
private String bankTransactionNo;
/** 收款日期 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date receivedAt;
/** 状态:PENDING待确认/CONFIRMED已确认/CANCELLED已取消 */
private String status;
/** 确认时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date confirmedAt;
/** 确认人 */
private Long confirmedBy;
/** 开票状态:NONE未开/PART部分/ALL全部 */
private String invoiceStatus;
/** 乐观锁版本号 */
private Integer version;
/** 删除标志(0存在 1删除) */
private String delFlag;
/** 收款附件列表(存 bms_customer_attachment,attachment_type='3',biz_id 关联本单) */
private List<BmsCustomerAttachment> attachmentList;
/** 收款附件数量(列表查询统计) */
private Integer attachmentCount;
}
@@ -0,0 +1,60 @@
package com.ruoyi.customer.customermoney.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.domain.CustomerReceipt;
/**
* 客户余额/收款/流水 Mapper
*
* @author ruoyi
* @date 2026-09-11
*/
public interface CustomerMoneyMapper
{
/* ================= 客户余额账户 ================= */
/** 按客户+币种查询账户 */
CustomerAccount selectAccountByCustomerCurrency(@Param("customerId") Long customerId,
@Param("currency") String currency);
/** 按客户+币种查询账户(行锁,审核事务内使用) */
CustomerAccount selectAccountForUpdate(@Param("customerId") Long customerId,
@Param("currency") String currency);
/** 查询客户全部币种账户 */
List<CustomerAccount> selectAccountListByCustomerId(@Param("customerId") Long customerId);
/** 新增账户 */
int insertAccount(CustomerAccount account);
/** 乐观锁更新余额 */
int updateAccountBalance(CustomerAccount account);
/* ================= 客户收款 ================= */
/** 查询收款单列表 */
List<CustomerReceipt> selectReceiptList(CustomerReceipt query);
/** 按ID查询收款单 */
CustomerReceipt selectReceiptById(@Param("id") Long id);
/** 新增收款单 */
int insertReceipt(CustomerReceipt receipt);
/** 审核通过:PENDING -> CONFIRMED(带状态+乐观锁条件) */
int confirmReceipt(CustomerReceipt receipt);
/** 取消收款单:PENDING -> CANCELLED */
int cancelReceipt(@Param("id") Long id, @Param("updateBy") String updateBy);
/* ================= 余额流水 ================= */
/** 查询流水列表 */
List<CustomerBalanceFlow> selectFlowList(CustomerBalanceFlow query);
/** 新增流水 */
int insertFlow(CustomerBalanceFlow flow);
}
@@ -0,0 +1,39 @@
package com.ruoyi.customer.customermoney.service;
import java.util.List;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.domain.CustomerReceipt;
/**
* 客户余额/收款/流水 Service接口
*
* @author ruoyi
* @date 2026-09-11
*/
public interface ICustomerMoneyService
{
/** 查询客户各币种账户 */
List<CustomerAccount> selectAccountList(Long customerId);
/** 查询收款单列表 */
List<CustomerReceipt> selectReceiptList(CustomerReceipt query);
/** 查询收款单详情(含附件) */
CustomerReceipt selectReceiptById(Long id);
/** 登记收款(状态默认 PENDING,同时保存附件) */
int insertReceipt(CustomerReceipt receipt);
/**
* 审核通过收款单:PENDING -> CONFIRMED
* 事务内:锁定账户(不存在则初始化)-> 变动余额/已用额度 -> 写余额流水
*/
int confirmReceipt(Long id);
/** 取消收款单(仅待确认可取消) */
int cancelReceipt(Long id);
/** 查询余额流水列表 */
List<CustomerBalanceFlow> selectFlowList(CustomerBalanceFlow query);
}
@@ -0,0 +1,253 @@
package com.ruoyi.customer.customermoney.service.impl;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.customer.customerinfo.domain.BmsCustomerAttachment;
import com.ruoyi.customer.customerinfo.mapper.BmsCustomerMapper;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.domain.CustomerReceipt;
import com.ruoyi.customer.customermoney.mapper.CustomerMoneyMapper;
import com.ruoyi.customer.customermoney.service.ICustomerMoneyService;
/**
* 客户余额/收款/流水 Service实现
*
* @author ruoyi
* @date 2026-09-11
*/
@Service
public class CustomerMoneyServiceImpl implements ICustomerMoneyService
{
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
@Autowired
private CustomerMoneyMapper customerMoneyMapper;
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
@Override
public List<CustomerAccount> selectAccountList(Long customerId)
{
return customerMoneyMapper.selectAccountListByCustomerId(customerId);
}
@Override
public List<CustomerReceipt> selectReceiptList(CustomerReceipt query)
{
return customerMoneyMapper.selectReceiptList(query);
}
@Override
public CustomerReceipt selectReceiptById(Long id)
{
CustomerReceipt receipt = customerMoneyMapper.selectReceiptById(id);
if (receipt != null)
{
BmsCustomerAttachment query = new BmsCustomerAttachment();
query.setBizId(id);
receipt.setAttachmentList(bmsCustomerMapper.selectBmsCustomerAttachmentList(query));
}
return receipt;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int insertReceipt(CustomerReceipt receipt)
{
if (receipt.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
if (receipt.getAmount() == null || receipt.getAmount().compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("收款金额必须大于0");
}
if (!StringUtils.hasText(receipt.getReceiptType()))
{
receipt.setReceiptType(CustomerReceipt.TYPE_PREPAY);
}
if (!StringUtils.hasText(receipt.getCurrency()))
{
receipt.setCurrency("CNY");
}
receipt.setReceiptNo(generateNo("SK"));
receipt.setStatus(CustomerReceipt.STATUS_PENDING);
receipt.setAllocatedAmount(BigDecimal.ZERO);
receipt.setUnallocatedAmount(receipt.getAmount());
if (!StringUtils.hasText(receipt.getInvoiceStatus()))
{
receipt.setInvoiceStatus("NONE");
}
receipt.setVersion(0);
customerMoneyMapper.insertReceipt(receipt);
// 收款附件:复用客户附件表,类型挂"其他(3)",biz_id 关联收款单
saveReceiptAttachments(receipt);
return 1;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int confirmReceipt(Long id)
{
CustomerReceipt receipt = customerMoneyMapper.selectReceiptById(id);
if (receipt == null)
{
throw new ServiceException("收款单不存在");
}
if (!CustomerReceipt.STATUS_PENDING.equals(receipt.getStatus()))
{
throw new ServiceException("仅待确认的收款单可以审核");
}
String currency = StringUtils.hasText(receipt.getCurrency()) ? receipt.getCurrency() : "CNY";
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
// 1. 行锁锁定客户币种账户,不存在则自动初始化
CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(receipt.getCustomerId(), currency);
if (account == null)
{
account = new CustomerAccount();
account.setCustomerId(receipt.getCustomerId());
account.setCurrency(currency);
account.setBalance(BigDecimal.ZERO);
account.setFrozenBalance(BigDecimal.ZERO);
account.setTotalCredit(BigDecimal.ZERO);
account.setExtraCredit(BigDecimal.ZERO);
account.setUsedCredit(BigDecimal.ZERO);
account.setTax(BigDecimal.ZERO);
account.setVersion(0);
account.setCreateBy(username);
customerMoneyMapper.insertAccount(account);
}
// 2. 变动余额(预充值/混合增加余额;还款核销释放已用额度)
BigDecimal amount = nz(receipt.getAmount());
BigDecimal beforeBalance = nz(account.getBalance());
BigDecimal afterBalance = beforeBalance.add(amount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
account.setBalance(afterBalance);
if (CustomerReceipt.TYPE_REPAY.equals(receipt.getReceiptType()))
{
account.setUsedCredit(nz(account.getUsedCredit()).subtract(amount).max(BigDecimal.ZERO));
}
account.setUpdateBy(username);
int accountRows = customerMoneyMapper.updateAccountBalance(account);
if (accountRows == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
// 3. 写余额流水
CustomerBalanceFlow flow = new CustomerBalanceFlow();
flow.setFlowNo(generateNo("LS"));
flow.setCustomerId(receipt.getCustomerId());
flow.setAccountId(account.getId());
flow.setCurrency(currency);
flow.setBizType(CustomerBalanceFlow.BIZ_RECEIPT);
flow.setBizId(receipt.getId());
flow.setBizNo(receipt.getReceiptNo());
flow.setDirection(1);
flow.setAmount(amount);
flow.setBeforeBalance(beforeBalance);
flow.setAfterBalance(afterBalance);
flow.setBeforeFrozenBalance(frozenBalance);
flow.setAfterFrozenBalance(frozenBalance);
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark("收款单审核通过:" + receipt.getReceiptNo());
customerMoneyMapper.insertFlow(flow);
// 4. 收款单置为已确认
receipt.setStatus(CustomerReceipt.STATUS_CONFIRMED);
receipt.setConfirmedAt(new Date());
receipt.setConfirmedBy(userId);
receipt.setUpdateBy(username);
int receiptRows = customerMoneyMapper.confirmReceipt(receipt);
if (receiptRows == 0)
{
throw new ServiceException("收款单状态已变更,请刷新后重试");
}
return receiptRows;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int cancelReceipt(Long id)
{
CustomerReceipt receipt = customerMoneyMapper.selectReceiptById(id);
if (receipt == null)
{
throw new ServiceException("收款单不存在");
}
if (!CustomerReceipt.STATUS_PENDING.equals(receipt.getStatus()))
{
throw new ServiceException("仅待确认的收款单可以取消");
}
return customerMoneyMapper.cancelReceipt(id, SecurityUtils.getUsername());
}
@Override
public List<CustomerBalanceFlow> selectFlowList(CustomerBalanceFlow query)
{
return customerMoneyMapper.selectFlowList(query);
}
/* ================= 私有方法 ================= */
/** 保存收款附件到客户附件表 */
private void saveReceiptAttachments(CustomerReceipt receipt)
{
if (CollectionUtils.isEmpty(receipt.getAttachmentList()))
{
return;
}
int sort = 0;
for (BmsCustomerAttachment attachment : receipt.getAttachmentList())
{
attachment.setAttachmentId(null);
attachment.setCustomerId(receipt.getCustomerId());
attachment.setBizId(receipt.getId());
attachment.setAttachmentType("3");
if (!StringUtils.hasText(attachment.getIsLogo()))
{
attachment.setIsLogo("0");
}
if (attachment.getSortOrder() == null)
{
attachment.setSortOrder(sort);
}
attachment.setDelFlag("0");
bmsCustomerMapper.insertBmsCustomerAttachment(attachment);
sort++;
}
}
/** 生成业务单号:前缀 + 时间戳(毫秒) + 3位随机数 */
private String generateNo(String prefix)
{
return prefix + LocalDateTime.now().format(NO_FORMATTER)
+ ThreadLocalRandom.current().nextInt(100, 1000);
}
private BigDecimal nz(BigDecimal value)
{
return value == null ? BigDecimal.ZERO : value;
}
}