统一修改ruoyi->bms

This commit is contained in:
caojia
2026-09-28 10:02:38 +08:00
parent 341afc4111
commit 0408b6c35e
639 changed files with 2776 additions and 2807 deletions
@@ -0,0 +1,63 @@
package com.bms.customer.customermoney.api;
import java.math.BigDecimal;
import lombok.Data;
/**
* 客户账户余额变动命令(扣减/回补余额,写余额流水;可选联动已用额度)。
*
* <p>典型场景:</p>
* <ul>
* <li>余额核销账单/订单扣款 -> {@link ICustomerAccountApi#deductBalance}(余额减少,默认同步释放已用额度)</li>
* <li>收款审核入账/反核销回补/退款 -> {@link ICustomerAccountApi#refundBalance}(余额增加)</li>
* </ul>
*
* @author bms
* @date 2026-09-17
*/
@Data
public class BalanceChangeCommand
{
/** 客户ID(必填) */
private Long customerId;
/** 币种(流水原币口径;为空按本位币 CNY 处理) */
private String currency;
/**
* 实际加锁/变动的账户币种(为空时取 {@link #currency})。
* 外币收款折算入账场景:账户只有CNY,需锁CNY账户按 {@link #baseAmount} 变动余额,
* 但流水币种仍保留收款原币 {@link #currency}。
*/
private String accountCurrency;
/** 变动金额,原币口径(必须大于0,恒为正数) */
private BigDecimal amount;
/** 对CNY汇率(1原币=?CNY),为空按1处理 */
private BigDecimal exchangeRate;
/** 本位币变动金额(实际参与余额变动,为空时取 {@link #amount}) */
private BigDecimal baseAmount;
/** 业务类型,取 {@link CustomerFundBizType} 常量(必填) */
private String bizType;
/** 关联业务单据ID */
private Long bizId;
/** 关联业务单据号 */
private String bizNo;
/** 流水备注 */
private String remark;
/** 扣减余额时是否同步释放等额已用额度(口径变更 2026-09-23 后已忽略,保留兼容历史调用) */
private Boolean releaseUsedCredit = Boolean.TRUE;
/** 回补余额时是否同步重新占用等额已用额度(口径变更 2026-09-23 后已忽略,保留兼容历史调用) */
private Boolean reoccupyUsedCredit = Boolean.FALSE;
/** 扣减时是否校验余额充足(不足抛异常),默认true */
private Boolean checkBalanceEnough = Boolean.TRUE;
}
@@ -0,0 +1,59 @@
package com.bms.customer.customermoney.api;
import java.math.BigDecimal;
import lombok.Data;
/**
* 授信额度占用/释放命令(变动客户账户已用额度 used_credit,并写额度流水)。
*
* <p>典型场景:</p>
* <ul>
* <li>计费订单开单/应收账单确认 -> {@link ICustomerAccountApi#occupyCredit}(已用额度增加)</li>
* <li>账单作废/计费变动核减 -> {@link ICustomerAccountApi#releaseCredit}(已用额度减少)</li>
* </ul>
* 客户标识 customerId / customerCode 二选一,传一个即可。
*
* @author bms
* @date 2026-09-17
*/
@Data
public class CreditUsageCommand
{
/**
* 客户ID,与 {@link #customerCode} 二选一(至少传一个)。
* 两者同时传值时以 customerId 为准。
*/
private Long customerId;
/**
* 客户编码,与 {@link #customerId} 二选一(至少传一个)。
* 未传 customerId 时由公共服务按编码解析客户档案(del_flag=0),
* 编码不存在或匹配多条时抛 ServiceException。
*/
private String customerCode;
/** 币种(为空按本位币 CNY 处理) */
private String currency;
/** 变动金额(必须大于0,恒为正数) */
private BigDecimal amount;
/** 业务来源类型,取 {@link CustomerFundBizType} 常量(必填) */
private String bizType;
/** 关联业务单据ID(如账单ID/订单ID) */
private Long bizId;
/** 关联业务单据号(如账单号/订单号,写入流水便于追溯) */
private String bizNo;
/** 流水原因(为空按业务类型+单据号自动生成) */
private String reason;
/**
* 字段保留兼容历史调用,当前计费策略(2026-09-20 起)已不再硬拦截:
* 超额客户计费继续占用信用额度,允许可用额度为负,后续改由超额提醒机制处理。
*/
@Deprecated
private Boolean checkAvailable = Boolean.TRUE;
}
@@ -0,0 +1,27 @@
package com.bms.customer.customermoney.api;
/**
* 客户资金变动业务来源类型(写入余额流水 biz_type / 额度流水 biz_type)。
* 各业务模块调用 {@link ICustomerAccountApi} 时传本类常量,便于按来源单据反查流水。
*
* @author bms
* @date 2026-09-17
*/
public final class CustomerFundBizType
{
/** 收款单(余额入账) */
public static final String RECEIPT = "RECEIPT";
/** 应收账单(确认占用授信 / 作废释放授信) */
public static final String AR_BILL = "AR_BILL";
/** 账单余额核销(余额扣款 / 反核销回补) */
public static final String BILL_PAY = "BILL_PAY";
/** 计费订单(开单占用授信 / 计费变动核减释放) */
public static final String ORDER = "ORDER";
private CustomerFundBizType()
{
}
}
@@ -0,0 +1,107 @@
package com.bms.customer.customermoney.api;
import com.bms.customer.customermoney.domain.CustomerAccount;
import com.bms.customer.customermoney.domain.CustomerBalanceFlow;
import com.bms.customer.customermoney.domain.CustomerCreditFlow;
import com.bms.customer.customermoney.vo.CustomerAccountStatusVO;
/**
* 客户资金公共服务:客户余额扣减/回补、授信额度占用/释放的统一入口。
*
* <p>客户余额、授信额度及其流水(客户模块资产)的所有变动都必须经过本接口,
* 其他模块(应收账单、计费订单/计费变动等)不得直接更新 ctr_customer_account /
* 直接插入流水表。每个方法在同一事务内完成:行锁账户 -> 乐观锁变动 -> 写流水,
* 调用方自身的事务会与本操作共享事务(默认 REQUIRED),任一失败整体回滚。</p>
*
* <ul>
* <li>单据形成应收/计费开单:{@link #occupyCredit} 占用授信(used_credit 增加)</li>
* <li>单据作废/计费核减:{@link #releaseCredit} 释放授信(used_credit 减少)</li>
* <li>用账户余额支付/核销:{@link #deductBalance} 扣余额(可同步释放已用额度)</li>
* <li>收款入账/退款/反核销回补:{@link #refundBalance} 加余额(可同步重新占用)</li>
* </ul>
*
* @author bms
* @date 2026-09-17
*/
public interface ICustomerAccountApi
{
/**
* 普通查询客户某币种账户(不加锁,不存在返回null),供业务模块判断余额/可用额度。
*/
CustomerAccount getAccount(Long customerId, String currency);
/**
* 事务内行锁获取客户CNY账户,不存在则按客户档案额度自动初始化(不处理授信过期)。
* 调用方拿到后如需最新额度,可再调 {@link #expireDueCreditsIfNeeded}。
*/
CustomerAccount lockCnyAccount(Long customerId);
/**
* 授信(信用额度/临时额度)到期自动失效:过期项额度清零 + 释放角色额度池
* + 回写客户档案 + 写 EXPIRE 额度流水。
* 入参必须是同一事务内行锁查出的账户;均未过期时不做任何操作。
*/
void expireDueCreditsIfNeeded(CustomerAccount account);
/**
* 扫描所有授信已到期(信用或临时额度&gt;0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效(行锁 -> 清零 -> 释放角色池 -> 回写档案 -> EXPIRE流水)。
* 单户失败仅记录日志不影响其他户;定时任务每日调用,应用启动后也会执行一次。
*
* @return 实际发生失效的账户数
*/
int expireAllDueCredits();
/**
* 新建客户时初始化默认CNY账户(余额0,信用额度默认0,档案有值按档案),
* 写 INIT 额度流水;CNY账户已存在时不重复创建。
*/
int initDefaultCnyAccount(Long customerId);
/**
* 计费占用:扣减账户余额(balance -= amount),写 -1 余额流水(口径变更 2026-09-23)。
* 不再变动 used_credit;额度仅在调整/到期失效时变化。允许负余额(欠费)继续计费。
* 命令中 customerId 与 customerCode 二选一;
* CNY账户不存在自动初始化,外币账户不存在时静默跳过(返回null)。
*
* @return 口径变更后不再产生额度流水,固定返回 null;账户不存在跳过场景也返回null
*/
CustomerCreditFlow occupyCredit(CreditUsageCommand command);
/**
* 计费释放:回补账户余额(balance += amount),写 +1 余额流水(口径变更 2026-09-23)。
* 不再变动 used_credit。命令中 customerId 与 customerCode 二选一;
* 账户不存在时静默跳过(返回null)。
*
* @return 口径变更后不再产生额度流水,固定返回 null;账户不存在跳过场景也返回null
*/
CustomerCreditFlow releaseCredit(CreditUsageCommand command);
/**
* 扣减账户余额:balance -= amount,写 -1 余额流水。
* 口径变更后不再联动 used_credit(releaseUsedCredit 字段已忽略)。
* 账户不存在或余额不足(checkBalanceEnough=true)抛 ServiceException。
*
* @return 已落库的余额流水(含ID、账户ID)
*/
CustomerBalanceFlow deductBalance(BalanceChangeCommand command);
/**
* 回补/入账账户余额:balance += amount,写 +1 余额流水。
* 口径变更后不再联动 used_credit(reoccupyUsedCredit 字段已忽略)。
*
* @return 已落库的余额流水(含ID、账户ID)
*/
CustomerBalanceFlow refundBalance(BalanceChangeCommand command);
/**
* 查询客户账号状态(收货扣件准入控制用)。支持 customerId / customerCode 二选一,只读轻量查询,
* 不锁账户也不物化临时额度失效——临时额度已过期时按"视为0"计算可用额度,但 DB 保持原状,
* 由定时任务或后续真实资金入口落库。
*
* <p>欠费定义:可用额度 &lt; 0(账户余额 + 信用额度 + 有效临时额度 不足)。</p>
*
* @return 账号状态VO;客户不存在时返回 null
*/
CustomerAccountStatusVO getAccountStatus(Long customerId, String customerCode);
}
@@ -0,0 +1,833 @@
package com.bms.customer.customermoney.api.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.SecurityUtils;
import com.bms.customer.customerinfo.domain.BmsCustomer;
import com.bms.customer.customerinfo.mapper.BmsCustomerMapper;
import com.bms.customer.customermoney.api.BalanceChangeCommand;
import com.bms.customer.customermoney.api.CreditUsageCommand;
import com.bms.customer.customermoney.api.CustomerFundBizType;
import com.bms.customer.customermoney.api.ICustomerAccountApi;
import com.bms.customer.customermoney.domain.CustomerAccount;
import com.bms.customer.customermoney.domain.CustomerBalanceFlow;
import com.bms.customer.customermoney.vo.CustomerAccountStatusVO;
import com.bms.customer.customermoney.domain.CustomerCreditFlow;
import com.bms.customer.customermoney.mapper.CustomerMoneyMapper;
import com.bms.system.domain.BmsExchangeRate;
/**
* 客户资金公共服务实现:客户账户余额/授信额度变动与流水落库的唯一收口。
* 加锁顺序统一为「业务单据(调用方持锁)-> 客户账户(本服务行锁)」。
*
* @author bms
* @date 2026-09-17
*/
@Service
public class CustomerAccountApiImpl implements ICustomerAccountApi
{
private static final Logger log = LoggerFactory.getLogger(CustomerAccountApiImpl.class);
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
/** 本位币币种 */
private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY;
/** 新客户默认总额度(CNY):默认0,信用额度一律通过「申请-审批」分配 */
private static final BigDecimal DEFAULT_CREDIT = BigDecimal.ZERO;
/** 金额保留位数 */
private static final int MONEY_SCALE = 2;
@Autowired
private CustomerMoneyMapper customerMoneyMapper;
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
/** 角色额度池台账:授信到期释放角色池 */
@Autowired
private com.bms.customer.customermoney.service.ICreditQuotaService creditQuotaService;
@Autowired
private PlatformTransactionManager transactionManager;
/** 逐户独立事务模板(REQUIRES_NEW):批量失效时单户失败回滚不影响其他户 */
private TransactionTemplate requireNewTxTemplate;
@PostConstruct
public void initTxTemplate()
{
requireNewTxTemplate = new TransactionTemplate(transactionManager);
requireNewTxTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
@Override
public CustomerAccount getAccount(Long customerId, String currency)
{
if (customerId == null)
{
return null;
}
return customerMoneyMapper.selectAccountByCustomerCurrency(customerId, resolveCurrency(currency));
}
@Override
public CustomerAccount lockCnyAccount(Long customerId)
{
if (customerId == null)
{
throw new ServiceException("客户ID不能为空");
}
return getOrInitCnyAccount(customerId, SecurityUtils.getUsername());
}
@Override
@Transactional(rollbackFor = Exception.class)
public int initDefaultCnyAccount(Long customerId)
{
if (customerId == null)
{
throw new ServiceException("客户ID不能为空");
}
String username = SecurityUtils.getUsername();
CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (account != null)
{
return 1;
}
BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId);
if (customer == null)
{
throw new ServiceException("客户不存在");
}
BigDecimal initCredit = nz(customer.getCreditEd());
if (initCredit.compareTo(BigDecimal.ZERO) < 0)
{
initCredit = BigDecimal.ZERO;
}
BigDecimal initTemp = nz(customer.getTempEd());
account = createCnyAccount(customerId, initCredit, initTemp, username);
// 档案额度与实际初始额度保持一致
bmsCustomerMapper.updateCustomerCredit(customerId, initCredit, initTemp, username);
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT,
initCredit, null, null, username, "新客户初始化账户,默认信用额度0(需通过额度申请审批分配)");
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
{
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
initTemp, null, null, username, "新客户初始化临时额度");
}
return 1;
}
@Override
public int expireAllDueCredits()
{
List<Long> customerIds = customerMoneyMapper.selectExpiredCreditCustomerIds();
if (customerIds == null || customerIds.isEmpty())
{
return 0;
}
int expired = 0;
for (Long customerId : customerIds)
{
// 每户独立事务:行锁 -> 失效清零 -> 释放角色池 -> 回写档案 -> EXPIRE流水,单户失败不影响其他户
try
{
Boolean changed = requireNewTxTemplate.execute(status -> {
CustomerAccount locked = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (locked == null
|| (!isTempCreditExpired(locked) && !isCreditExpired(locked)))
{
// 可能被其他节点/入口抢先处理
return Boolean.FALSE;
}
expireDueCreditsIfNeeded(locked);
return Boolean.TRUE;
});
if (Boolean.TRUE.equals(changed))
{
expired++;
}
}
catch (Exception e)
{
log.error("客户{}授信到期自动失效失败", customerId, e);
}
}
log.info("授信到期批量失效完成,扫描{}户,实际失效{}户", customerIds.size(), expired);
return expired;
}
@Override
public void expireDueCreditsIfNeeded(CustomerAccount account)
{
if (account == null)
{
return;
}
boolean creditExpired = isCreditExpired(account);
boolean tempExpired = isTempCreditExpired(account);
if (!creditExpired && !tempExpired)
{
return;
}
// 定时任务场景无登录态,操作人回落为 system(人工触发的入口仍记录实际操作人)
String username = currentUsername();
Long userId = currentUserId();
BigDecimal beforeCredit = nz(account.getTotalCredit());
BigDecimal beforeExtra = nz(account.getExtraCredit());
if (creditExpired)
{
account.setTotalCredit(BigDecimal.ZERO);
}
if (tempExpired)
{
account.setExtraCredit(BigDecimal.ZERO);
}
account.setUpdateBy(username);
int rows = customerMoneyMapper.updateAccountCredit(account);
if (rows == 0)
{
throw new ServiceException("账户额度已被其他操作变动,请刷新后重试");
}
account.setVersion(account.getVersion() + 1);
bmsCustomerMapper.updateCustomerCredit(account.getCustomerId(),
nz(account.getTotalCredit()), nz(account.getExtraCredit()), username);
// 释放对应角色额度池(无台账背书的历史额度释放金额为0,不影响池余额)
if (creditExpired)
{
creditQuotaService.releaseAll(account.getCustomerId(),
CustomerCreditFlow.CREDIT_TYPE_CREDIT, username);
writeExpireFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT, beforeCredit,
account.getCreditEffectiveDate(), account.getCreditExpireDate(),
username, userId, "信用额度到期自动失效");
}
if (tempExpired)
{
creditQuotaService.releaseAll(account.getCustomerId(),
CustomerCreditFlow.CREDIT_TYPE_TEMP, username);
writeExpireFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP, beforeExtra,
account.getExtraEffectiveDate(), account.getExtraExpireDate(),
username, userId, "临时额度到期自动失效");
}
}
/** 到期失效写 EXPIRE 额度流水 */
private void writeExpireFlow(CustomerAccount account, String creditType, BigDecimal before,
Date effectiveDate, Date expireDate,
String username, Long userId, String reason)
{
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(creditType);
flow.setChangeType(CustomerCreditFlow.CHANGE_EXPIRE);
flow.setDirection(-1);
flow.setBeforeAmount(before);
flow.setChangeAmount(before);
flow.setAfterAmount(BigDecimal.ZERO);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(effectiveDate);
flow.setExpireDate(expireDate);
flow.setReason(reason);
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark("系统在访问账户或定时任务中检测到额度已过失效日期,自动置零");
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
/** 当前登录用户名;无登录态(定时任务)时返回 system */
private String currentUsername()
{
try
{
return SecurityUtils.getUsername();
}
catch (Exception e)
{
return "system";
}
}
/** 当前登录用户ID;无登录态(定时任务)时返回null */
private Long currentUserId()
{
try
{
return SecurityUtils.getUserId();
}
catch (Exception e)
{
return null;
}
}
/* ================= 计费扣减 / 回补余额(口径变更:不再变动 used_credit) ================= */
// 2026-09-23 口径变更:计费占用/释放改为扣减/回补账户余额,允许负余额(欠费)继续计费。
// 额度(totalCredit/extraCredit)仅在额度调整或到期失效时变化,计费不再影响 used_credit。
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerCreditFlow occupyCredit(CreditUsageCommand command)
{
validateUsageCommand(command);
Long customerId = resolveCustomerId(command);
String currency = resolveCurrency(command.getCurrency());
String username = currentUsername();
Long userId = currentUserId();
CustomerAccount account = lockAccountForUsage(customerId, currency, username);
if (account == null)
{
// 外币账户尚未开立时不自动占用,与历史账单口径保持一致(CNY账户会自动初始化)
return null;
}
BigDecimal amount = money(command.getAmount());
BigDecimal beforeBalance = nz(account.getBalance());
// 计费口径变更:occupyCredit 改为扣减账户余额,允许负余额(欠费),不再变动 used_credit
BigDecimal afterBalance = beforeBalance.subtract(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
account.setBalance(afterBalance);
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
}
// 写余额流水(计费扣减),不再写额度流水
BigDecimal frozenBalance = nz(account.getFrozenBalance());
CustomerBalanceFlow flow = newBalanceFlow(account, toBalanceCommand(command, currency, "计费扣减余额"),
currency, amount, -1, beforeBalance, afterBalance, frozenBalance, frozenBalance, username, userId);
customerMoneyMapper.insertFlow(flow);
// 接口契约返回额度流水;口径变更后不再产生额度变动,返回 null(计费侧未使用返回值)
return null;
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerCreditFlow releaseCredit(CreditUsageCommand command)
{
validateUsageCommand(command);
Long customerId = resolveCustomerId(command);
String currency = resolveCurrency(command.getCurrency());
String username = currentUsername();
Long userId = currentUserId();
CustomerAccount account = lockAccountForUsage(customerId, currency, username);
if (account == null)
{
return null;
}
BigDecimal amount = money(command.getAmount());
BigDecimal beforeBalance = nz(account.getBalance());
// 计费口径变更:releaseCredit 改为回补账户余额,不再变动 used_credit
BigDecimal afterBalance = beforeBalance.add(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
account.setBalance(afterBalance);
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
}
BigDecimal frozenBalance = nz(account.getFrozenBalance());
CustomerBalanceFlow flow = newBalanceFlow(account, toBalanceCommand(command, currency, "计费回补余额"),
currency, amount, 1, beforeBalance, afterBalance, frozenBalance, frozenBalance, username, userId);
customerMoneyMapper.insertFlow(flow);
return null;
}
/* ================= 余额扣减 / 回补 ================= */
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerBalanceFlow deductBalance(BalanceChangeCommand command)
{
validateBalanceCommand(command);
String flowCurrency = resolveCurrency(command.getCurrency());
String accountCurrency = StringUtils.hasText(command.getAccountCurrency())
? resolveCurrency(command.getAccountCurrency()) : flowCurrency;
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForChange(command.getCustomerId(), accountCurrency, username, "扣款");
BigDecimal originalAmount = money(command.getAmount());
// 账户币种实际变动额:折算入账时取本位币金额,其余场景等于原币金额
BigDecimal changeAmount = command.getBaseAmount() != null ? money(command.getBaseAmount()) : originalAmount;
BigDecimal beforeBalance = nz(account.getBalance());
if (!Boolean.FALSE.equals(command.getCheckBalanceEnough()) && beforeBalance.compareTo(changeAmount) < 0)
{
throw new ServiceException("账户余额不足,当前" + accountCurrency + "余额:" + beforeBalance.toPlainString()
+ ",本次需扣减:" + changeAmount.toPlainString());
}
BigDecimal afterBalance = beforeBalance.subtract(changeAmount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
account.setBalance(afterBalance);
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
CustomerBalanceFlow flow = newBalanceFlow(account, command, flowCurrency, originalAmount, -1,
beforeBalance, afterBalance, frozenBalance, frozenBalance, username, userId);
customerMoneyMapper.insertFlow(flow);
return flow;
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerBalanceFlow refundBalance(BalanceChangeCommand command)
{
validateBalanceCommand(command);
String flowCurrency = resolveCurrency(command.getCurrency());
String accountCurrency = StringUtils.hasText(command.getAccountCurrency())
? resolveCurrency(command.getAccountCurrency()) : flowCurrency;
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForChange(command.getCustomerId(), accountCurrency, username, "回补");
BigDecimal originalAmount = money(command.getAmount());
// 账户币种实际变动额:折算入账时取本位币金额,其余场景等于原币金额
BigDecimal changeAmount = command.getBaseAmount() != null ? money(command.getBaseAmount()) : originalAmount;
BigDecimal beforeBalance = nz(account.getBalance());
BigDecimal afterBalance = beforeBalance.add(changeAmount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
account.setBalance(afterBalance);
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
CustomerBalanceFlow flow = newBalanceFlow(account, command, flowCurrency, originalAmount, 1,
beforeBalance, afterBalance, frozenBalance, frozenBalance, username, userId);
customerMoneyMapper.insertFlow(flow);
return flow;
}
/* ================= 内部实现 ================= */
/** 额度占用命令 -> 余额变动命令(计费口径变更后,occupy/release 复用余额流水落库) */
private BalanceChangeCommand toBalanceCommand(CreditUsageCommand source, String currency, String reason)
{
BalanceChangeCommand target = new BalanceChangeCommand();
target.setCustomerId(source.getCustomerId());
target.setCurrency(currency);
target.setAmount(money(source.getAmount()));
target.setBizType(source.getBizType());
target.setBizId(source.getBizId());
target.setBizNo(source.getBizNo());
target.setRemark(reason);
return target;
}
/** 余额变动写余额流水(原币金额留痕,本位币金额参与余额变动) */
private CustomerBalanceFlow newBalanceFlow(CustomerAccount account, BalanceChangeCommand command,
String currency, BigDecimal amount, int direction,
BigDecimal beforeBalance, BigDecimal afterBalance,
BigDecimal beforeFrozen, BigDecimal afterFrozen,
String username, Long userId)
{
BigDecimal rate = command.getExchangeRate() != null
&& command.getExchangeRate().compareTo(BigDecimal.ZERO) > 0
? command.getExchangeRate() : BigDecimal.ONE;
BigDecimal baseAmount = command.getBaseAmount() != null ? command.getBaseAmount()
: amount.multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
CustomerBalanceFlow flow = new CustomerBalanceFlow();
flow.setFlowNo(generateNo("LS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCurrency(currency);
flow.setExchangeRate(rate.setScale(8, RoundingMode.HALF_UP));
flow.setBaseAmount(baseAmount);
flow.setBizType(command.getBizType());
flow.setBizId(command.getBizId());
flow.setBizNo(command.getBizNo());
flow.setDirection(direction);
flow.setAmount(amount);
flow.setBeforeBalance(beforeBalance);
flow.setAfterBalance(afterBalance);
flow.setBeforeFrozenBalance(beforeFrozen);
flow.setAfterFrozenBalance(afterFrozen);
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark(command.getRemark());
return flow;
}
/**
* 占用/释放场景锁账户:CNY 不存在自动初始化(先处理临时额度过期),
* 外币账户不存在返回null(调用方静默跳过)。
*/
private CustomerAccount lockAccountForUsage(Long customerId, String currency, String username)
{
if (BASE_CURRENCY.equals(currency))
{
CustomerAccount account = getOrInitCnyAccount(customerId, username);
expireDueCreditsIfNeeded(account);
return account;
}
return customerMoneyMapper.selectAccountForUpdate(customerId, currency);
}
/**
* 余额变动场景锁账户:账户不存在直接抛异常(余额扣款/回补必须有账户)。
*/
private CustomerAccount lockAccountForChange(Long customerId, String currency, String username, String action)
{
CustomerAccount account;
if (BASE_CURRENCY.equals(currency))
{
account = getOrInitCnyAccount(customerId, username);
expireDueCreditsIfNeeded(account);
}
else
{
account = customerMoneyMapper.selectAccountForUpdate(customerId, currency);
}
if (account == null)
{
throw new ServiceException("客户没有" + currency + "账户,无法" + action);
}
return account;
}
/** 行锁获取客户CNY账户,不存在则按客户档案当前额度初始化创建 */
private CustomerAccount getOrInitCnyAccount(Long customerId, String username)
{
CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (account != null)
{
return account;
}
BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId);
if (customer == null)
{
throw new ServiceException("客户不存在");
}
BigDecimal initCredit = nz(customer.getCreditEd());
BigDecimal initTemp = nz(customer.getTempEd());
try
{
account = createCnyAccount(customerId, initCredit, initTemp, username);
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT,
initCredit, null, null, username, "首次创建CNY账户,从客户档案同步初始额度");
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
{
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
initTemp, null, null, username, "首次创建CNY账户,从客户档案同步临时额度");
}
return account;
}
catch (RuntimeException e)
{
// Concurrent first-create: peer may have inserted; re-lock existing row.
if (isAccountInitRace(e))
{
account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (account != null)
{
log.warn("CNY account created by peer, reuse customerId={}", customerId);
return account;
}
}
throw e;
}
}
/** Deadlock / duplicate key while inserting ctr_customer_account. */
private static boolean isAccountInitRace(Throwable e)
{
Throwable t = e;
while (t != null)
{
String name = t.getClass().getName();
if (name.indexOf("DeadlockLoser") >= 0
|| name.indexOf("DuplicateKey") >= 0
|| name.indexOf("MySQLTransactionRollback") >= 0
|| name.indexOf("DataIntegrityViolation") >= 0)
{
return true;
}
String msg = t.getMessage();
if (msg != null)
{
String m = msg.toLowerCase();
if (m.indexOf("deadlock") >= 0
|| m.indexOf("duplicate entry") >= 0
|| m.indexOf("try restarting transaction") >= 0)
{
return true;
}
}
t = t.getCause();
}
return false;
}
/** 创建CNY账户(余额0,已用0) */
private CustomerAccount createCnyAccount(Long customerId, BigDecimal totalCredit,
BigDecimal extraCredit, String username)
{
CustomerAccount account = new CustomerAccount();
account.setCustomerId(customerId);
account.setCurrency(BASE_CURRENCY);
account.setBalance(BigDecimal.ZERO);
account.setFrozenBalance(BigDecimal.ZERO);
account.setTotalCredit(totalCredit);
account.setExtraCredit(extraCredit);
account.setUsedCredit(BigDecimal.ZERO);
account.setTax(BigDecimal.ZERO);
account.setVersion(0);
account.setCreateBy(username);
customerMoneyMapper.insertAccount(account);
return account;
}
/** 账户首次初始化时写额度 INIT 流水(从客户档案额度同步) */
private void writeInitFlow(CustomerAccount account, String creditType,
BigDecimal initAmount, Date effectiveDate, Date expireDate,
String username, String remark)
{
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(creditType);
flow.setChangeType(CustomerCreditFlow.CHANGE_INIT);
flow.setDirection(initAmount.compareTo(BigDecimal.ZERO) > 0 ? 1 : 0);
flow.setBeforeAmount(BigDecimal.ZERO);
flow.setChangeAmount(initAmount);
flow.setAfterAmount(initAmount);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(effectiveDate);
flow.setExpireDate(expireDate);
flow.setReason("账户初始化");
// No login (open API / MQ / job): operatorId null, same as occupyCredit
flow.setOperatorId(currentUserId());
flow.setCreateBy(username);
flow.setRemark(remark);
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
/**
* 计费开单准入:客户档案必须存在且为启用状态(is_enable='0')。
* 删除(del_flag='1',查询返回null)或停用(is_enable='1')均拒绝新增授信占用。
*/
private void assertCustomerActive(Long customerId)
{
BmsCustomer customer = bmsCustomerMapper.selectBmsCustomerByCustomerId(customerId);
if (customer == null)
{
throw new ServiceException("客户不存在或已删除,不可计费开单(客户ID:" + customerId + ")");
}
if ("1".equals(customer.getIsEnable()))
{
throw new ServiceException("客户【" + customer.getCustomerName() + "】已停用,不可计费开单");
}
}
private void validateUsageCommand(CreditUsageCommand command)
{
if (command == null || (command.getCustomerId() == null && !StringUtils.hasText(command.getCustomerCode())))
{
throw new ServiceException("客户ID与客户编码至少传一个");
}
if (!StringUtils.hasText(command.getBizType()))
{
throw new ServiceException("业务类型不能为空");
}
if (command.getAmount() == null || command.getAmount().compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("额度变动金额必须大于0");
}
}
/**
* 解析额度命令的客户标识:customerId 优先;未传时按 customerCode 精确查询有效客户档案。
* 编码不存在或匹配多条时抛异常,避免资金变动落到错误客户。
*/
private Long resolveCustomerId(CreditUsageCommand command)
{
if (command.getCustomerId() != null)
{
return command.getCustomerId();
}
Long customerId = resolveCustomerIdByCode(command.getCustomerCode());
// 回填,便于调用方/流水排查时直接取到解析结果
command.setCustomerId(customerId);
return customerId;
}
/**
* 按客户编码精确查询有效客户(selectBmsCustomerList 自带 del_flag='0' 过滤)。
* 编码不存在或匹配多条时抛异常,避免资金/状态判定落到错误客户。
*/
Long resolveCustomerIdByCode(String customerCode)
{
if (!StringUtils.hasText(customerCode))
{
throw new ServiceException("客户编码不能为空");
}
String code = customerCode.trim();
BmsCustomer query = new BmsCustomer();
query.setCustomerCode(code);
List<BmsCustomer> customers = bmsCustomerMapper.selectBmsCustomerList(query);
if (customers == null || customers.isEmpty())
{
throw new ServiceException("客户编码不存在:" + code);
}
if (customers.size() > 1)
{
throw new ServiceException("客户编码匹配到多个客户:" + code);
}
return customers.get(0).getCustomerId();
}
@Override
public CustomerAccountStatusVO getAccountStatus(Long customerId, String customerCode)
{
if (customerId == null && !StringUtils.hasText(customerCode))
{
throw new ServiceException("客户ID与客户编码至少传一个");
}
Long cid = customerId != null ? customerId : resolveCustomerIdByCode(customerCode);
// 1. 读客户档案(不加锁)
BmsCustomer cust = bmsCustomerMapper.selectBmsCustomerByCustomerId(cid);
if (cust == null)
{
return null;
}
// 2. 读CNY账户(普通读不加锁;新客户尚未初始化账户时按0额度处理)
CustomerAccount account = customerMoneyMapper.selectAccountByCustomerCurrency(cid, BASE_CURRENCY);
// 3. 组装状态VO
CustomerAccountStatusVO vo = new CustomerAccountStatusVO();
vo.setCustomerId(cid);
vo.setCustomerCode(cust.getCustomerCode());
vo.setCustomerName(cust.getCustomerName());
vo.setIsEnable(cust.getIsEnable());
vo.setIsEnableLabel("0".equals(cust.getIsEnable()) ? "正常" : "停用");
BigDecimal balance = account != null ? nz(account.getBalance()) : BigDecimal.ZERO;
BigDecimal totalCredit = account != null ? nz(account.getTotalCredit()) : BigDecimal.ZERO;
BigDecimal rawExtra = account != null ? nz(account.getExtraCredit()) : BigDecimal.ZERO;
// 已过期的信用/临时额度视为0(仅内存计算,不物化DB;由定时任务/真实资金入口落库)
boolean creditExpiredPending = isCreditExpired(account);
boolean expiredPending = isTempCreditExpired(account);
BigDecimal effectiveTotal = creditExpiredPending ? BigDecimal.ZERO : totalCredit;
BigDecimal effectiveExtra = expiredPending ? BigDecimal.ZERO : rawExtra;
vo.setBalance(balance);
vo.setFrozenBalance(account != null ? nz(account.getFrozenBalance()) : BigDecimal.ZERO);
vo.setTotalCredit(totalCredit);
vo.setEffectiveTotalCredit(effectiveTotal);
vo.setExtraCredit(rawExtra);
vo.setEffectiveExtraCredit(effectiveExtra);
// 可用额度 = 账户余额 + 信用额度 + 有效临时额度(2026-09-23 口径:计费扣减余额,已用额度不再参与)
BigDecimal available = balance.add(effectiveTotal).add(effectiveExtra);
vo.setAvailableCredit(available);
boolean overdue = available.compareTo(BigDecimal.ZERO) < 0;
vo.setOverdue(overdue);
vo.setExpiredTempCreditPending(expiredPending || creditExpiredPending);
boolean disabled = "1".equals(cust.getIsEnable());
if (disabled && overdue)
{
vo.setAccountStatus(CustomerAccountStatusVO.STATUS_DISABLED_AND_OVERDUE);
vo.setAccountStatusDesc("客户已停用且已超额(欠费),停用客户不可新计费");
}
else if (disabled)
{
vo.setAccountStatus(CustomerAccountStatusVO.STATUS_DISABLED);
vo.setAccountStatusDesc("客户已停用,不可新计费");
}
else if (overdue)
{
vo.setAccountStatus(CustomerAccountStatusVO.STATUS_OVERDUE);
vo.setAccountStatusDesc("客户已超额(欠费),可继续计费占用额度,请及时催收回款");
}
else
{
vo.setAccountStatus(CustomerAccountStatusVO.STATUS_NORMAL);
vo.setAccountStatusDesc("客户状态正常,可正常下单");
}
return vo;
}
private void validateBalanceCommand(BalanceChangeCommand command)
{
if (command == null || command.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
if (!StringUtils.hasText(command.getBizType()))
{
throw new ServiceException("业务类型不能为空");
}
if (command.getAmount() == null || command.getAmount().compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("余额变动金额必须大于0");
}
}
/** 临时额度是否已过期:有失效日期且今天已晚于失效日(失效日当天仍有效) */
private boolean isTempCreditExpired(CustomerAccount account)
{
return account != null
&& nz(account.getExtraCredit()).compareTo(BigDecimal.ZERO) > 0
&& account.getExtraExpireDate() != null
&& LocalDate.now().isAfter(account.getExtraExpireDate().toInstant()
.atZone(java.time.ZoneId.systemDefault()).toLocalDate());
}
/** 信用额度是否已过期:口径同临时额度(失效日当天仍有效) */
private boolean isCreditExpired(CustomerAccount account)
{
return account != null
&& nz(account.getTotalCredit()).compareTo(BigDecimal.ZERO) > 0
&& account.getCreditExpireDate() != null
&& LocalDate.now().isAfter(account.getCreditExpireDate().toInstant()
.atZone(java.time.ZoneId.systemDefault()).toLocalDate());
}
private String resolveCurrency(String currency)
{
return StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY;
}
private BigDecimal money(BigDecimal value)
{
return nz(value).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
/** 生成业务单号:前缀 + 时间戳(毫秒) + 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;
}
}
@@ -0,0 +1,70 @@
package com.bms.customer.customermoney.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户余额账户 ctr_customer_account
*
* @author bms
* @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;
/** 总额度(信用额度,默认0;配置有效期后到期次日自动清零) */
private BigDecimal totalCredit;
/** 信用额度生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date creditEffectiveDate;
/** 信用额度失效日期(空=长期;到期次日自动清零) */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date creditExpireDate;
/** 额外额度(临时额度) */
private BigDecimal extraCredit;
/** 临时额度生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date extraEffectiveDate;
/** 临时额度失效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date extraExpireDate;
/** 已用额度 */
private BigDecimal usedCredit;
/** 税金 */
private BigDecimal tax;
/** 乐观锁版本号 */
private Integer version;
/** 删除标志(0存在 1删除) */
private String delFlag;
}
@@ -0,0 +1,86 @@
package com.bms.customer.customermoney.domain;
import java.math.BigDecimal;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户余额流水 ctr_customer_balance_flow
*
* @author bms
* @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;
/** 对CNY汇率(1原币=?CNY,CNY为1) */
private BigDecimal exchangeRate;
/** 本位币金额(CNY,实际参与余额变动的金额) */
private BigDecimal baseAmount;
/** 业务类型: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,108 @@
package com.bms.customer.customermoney.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户额度调整申请审批 ctr_customer_credit_apply
* 信用额度/额外额度(临时额度)调整先申请,审批通过后才真正调整账户额度
*
* @author bms
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerCreditApply extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 待审批 */
public static final String STATUS_PENDING = "PENDING";
/** 已通过 */
public static final String STATUS_APPROVED = "APPROVED";
/** 已驳回 */
public static final String STATUS_REJECTED = "REJECTED";
/** 已撤销 */
public static final String STATUS_CANCELLED = "CANCELLED";
/** 申请单ID */
private Long applyId;
/** 申请单号(EDSQ前缀) */
private String applyNo;
/** 客户ID */
private Long customerId;
/** 客户账户ID */
private Long accountId;
/** 额度类型:CREDIT信用额度 TEMP临时额度(额外额度) */
private String creditType;
/** 调整方式:SET设置为 ADD增加 SUB减少 */
private String adjustMode;
/** 调整金额(正数) */
private java.math.BigDecimal changeAmount;
/** 临时额度生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date effectiveDate;
/** 临时额度失效日期(空=长期) */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date expireDate;
/** 调整原因 */
private String reason;
/** 状态:PENDING/APPROVED/REJECTED/CANCELLED */
private String status;
/** 申请人 */
private String applyBy;
/** 申请人ID */
private Long applyUserId;
/** 申请时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date applyTime;
/** 审批人 */
private String approveBy;
/** 审批人ID */
private Long approveUserId;
/** 审批占用的额度池角色ID(NULL=超管审批不占池;调减/到期释放时不消费池) */
private Long approveRoleId;
/** 审批时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date approveTime;
/** 审批意见/驳回原因 */
private String approveRemark;
/** 审批通过后生成的额度流水ID */
private Long flowId;
/** 删除标志 */
private String delFlag;
/* -------- 展示字段 -------- */
/** 客户编码 */
private String customerCode;
/** 客户名称 */
private String customerName;
/** 额度池角色名称(关联展示) */
private String approveRoleName;
}
@@ -0,0 +1,109 @@
package com.bms.customer.customermoney.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户额度流水 ctr_customer_credit_flow
*
* @author bms
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerCreditFlow extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 额度类型:信用额度(长期) */
public static final String CREDIT_TYPE_CREDIT = "CREDIT";
/** 额度类型:临时额度 */
public static final String CREDIT_TYPE_TEMP = "TEMP";
/** 变动类型:初始化 */
public static final String CHANGE_INIT = "INIT";
/** 变动类型:手工调整 */
public static final String CHANGE_ADJUST = "ADJUST";
/** 变动类型:到期自动失效 */
public static final String CHANGE_EXPIRE = "EXPIRE";
/** 变动类型:业务单据占用授信(已用额度增加,如账单确认/计费订单开单) */
public static final String CHANGE_OCCUPY = "OCCUPY";
/** 变动类型:业务单据释放授信(已用额度减少,如账单作废/计费变动核减) */
public static final String CHANGE_RELEASE = "RELEASE";
/** 调整方式:设置为 */
public static final String MODE_SET = "SET";
/** 调整方式:增加 */
public static final String MODE_ADD = "ADD";
/** 调整方式:减少 */
public static final String MODE_SUB = "SUB";
/** 主键 */
private Long id;
/** 流水号 */
private String flowNo;
/** 客户ID */
private Long customerId;
/** 客户账户ID */
private Long accountId;
/** 额度类型:CREDIT信用额度 TEMP临时额度 */
private String creditType;
/** 变动类型:INIT初始化 ADJUST调整 EXPIRE到期失效 OCCUPY授信占用 RELEASE授信释放 */
private String changeType;
/** 业务来源类型:AR_BILL应收账单 ORDER计费订单(仅占用/释放流水) */
private String bizType;
/** 关联业务单据ID */
private Long bizId;
/** 关联业务单据号 */
private String bizNo;
/** 变动方向:1增加 -1减少 0持平 */
private Integer direction;
/** 变动前额度 */
private BigDecimal beforeAmount;
/** 变动额度(恒为非负数,方向看 direction) */
private BigDecimal changeAmount;
/** 变动后额度 */
private BigDecimal afterAmount;
/** 变动时已用额度快照 */
private BigDecimal usedCredit;
/** 生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date effectiveDate;
/** 失效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date expireDate;
/** 调整原因 */
private String reason;
/** 调整方式(仅ADJUST流水有值):SET/ADD/SUB */
private String adjustMode;
/** 操作人ID */
private Long operatorId;
/** 额度池角色ID(ADJUST 审批调整时记录本次占用/释放的角色池;NULL=超管/系统) */
private Long roleId;
/** 删除标志(0存在 1删除) */
private String delFlag;
}
@@ -0,0 +1,90 @@
package com.bms.customer.customermoney.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户授信额度分配台账 ctr_customer_credit_grant
* 审批通过的净增额度占一行(占用角色额度池);调减/到期按 FIFO 核销行余额。
* 角色池已用 = SUM(remaining WHERE role_id=? AND status='0')
*
* @author bms
* @date 2026-09-20
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerCreditGrant extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 占用中 */
public static final String STATUS_ACTIVE = "0";
/** 已释放 */
public static final String STATUS_RELEASED = "1";
/** 台账ID */
private Long grantId;
/** 客户ID */
private Long customerId;
/** 客户账户ID */
private Long accountId;
/** 占用的额度池角色ID(NULL=超管审批/不占池) */
private Long roleId;
/** 审批人ID */
private Long approveUserId;
/** 额度申请单ID */
private Long applyId;
/** 额度流水ID */
private Long flowId;
/** 额度类型:CREDIT信用额度 TEMP临时额度 */
private String creditType;
/** 原始占用池金额 */
private BigDecimal amount;
/** 仍占用池的待释放余额 */
private BigDecimal remaining;
/** 生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date effectiveDate;
/** 失效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date expireDate;
/** 状态:0占用中 1已释放 */
private String status;
/** 全部释放时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date releaseTime;
/** 删除标志 */
private String delFlag;
/* -------- 展示字段 -------- */
/** 客户编码 */
private String customerCode;
/** 客户名称 */
private String customerName;
/** 角色名称 */
private String roleName;
/** 审批人用户名 */
private String approveBy;
}
@@ -0,0 +1,130 @@
package com.bms.customer.customermoney.domain;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.core.domain.BaseEntity;
import com.bms.customer.customerinfo.domain.BmsCustomerAttachment;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 客户收款 ctr_customer_receipt
*
* @author bms
* @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";
/** 汇率来源:系统默认(本币CNY 1:1) */
public static final String RATE_SOURCE_SYSTEM = "SYSTEM";
/** 汇率来源:汇率表(收款日即期汇率自动带出) */
public static final String RATE_SOURCE_RATE_TABLE = "RATE_TABLE";
/** 汇率来源:手工修改/手工录入 */
public static final String RATE_SOURCE_MANUAL = "MANUAL";
/** 主键 */
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;
/** 对CNY汇率(1原币=?CNY,CNY为1;默认取汇率表,可手工修改) */
private BigDecimal exchangeRate;
/** 汇率来源:SYSTEM系统默认(本币1:1)/RATE_TABLE汇率表/MANUAL手工修改 */
private String rateSource;
/** 汇率来源说明(汇率表来源及命中路径,或手工录入说明) */
private String rateSourceDesc;
/** 本位币金额(CNY,原币金额×汇率) */
private BigDecimal baseAmount;
/** 收款方式:BANK_TRANSFER/ALIPAY/WECHAT/CASH/OTHER */
private String payMethod;
/** 客户银行账户ID(ar_bank_account) */
private Long bankAccountId;
/** 客户银行账户户名(登记时快照) */
private String bankAccountName;
/** 客户银行账号(登记时快照) */
private String bankAccountNo;
/** 开户行名称(登记时快照) */
private String bankName;
/** 银行流水号/支付平台流水号 */
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;
/** 收款附件列表(存 ctr_customer_attachment,attachment_type='3',biz_id 关联本单) */
private List<BmsCustomerAttachment> attachmentList;
/** 收款附件数量(列表查询统计) */
private Integer attachmentCount;
}
@@ -0,0 +1,29 @@
package com.bms.customer.customermoney.domain;
import java.math.BigDecimal;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 角色可分配授信额度池 sys_role_credit_quota
* 一个角色一行;没有记录视为额度0(该角色用户不能审批任何增量授信)
*
* @author bms
* @date 2026-09-20
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SysRoleCreditQuota extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 角色ID */
private Long roleId;
/** 可分配额度总额(CNY) */
private BigDecimal totalQuota;
/** 乐观锁版本号 */
private Integer version;
}
@@ -0,0 +1,53 @@
package com.bms.customer.customermoney.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.customer.customermoney.domain.CustomerCreditGrant;
import com.bms.customer.customermoney.domain.SysRoleCreditQuota;
import com.bms.customer.customermoney.vo.RoleCreditQuotaVo;
import java.math.BigDecimal;
/**
* 角色额度池 / 授信分配台账 Mapper
*
* @author bms
* @date 2026-09-20
*/
public interface CreditQuotaMapper
{
/* ================= 角色额度池 ================= */
/** 全部角色的额度池(左连接,未配置额度的角色总额度按0返回;used 由台账 remaining 实时求和) */
List<RoleCreditQuotaVo> selectRoleQuotaList();
/** 指定用户名下正常角色的额度池(sys_user_role 关联) */
List<RoleCreditQuotaVo> selectRoleQuotaListByUserId(@Param("userId") Long userId);
/** 行锁取额度池配置(不存在返回null) */
SysRoleCreditQuota selectQuotaForUpdate(@Param("roleId") Long roleId);
/** 角色池已分配额(占用中台账 remaining 合计;调用前需先持有该池行锁) */
BigDecimal sumActiveRemaining(@Param("roleId") Long roleId);
/** 新建额度池配置 */
int insertQuota(SysRoleCreditQuota quota);
/** 修改额度池总额度(乐观锁) */
int updateQuota(SysRoleCreditQuota quota);
/* ================= 授信分配台账 ================= */
/** 台账查询(带客户名/角色名) */
List<CustomerCreditGrant> selectGrantList(CustomerCreditGrant query);
/** 行锁取某客户某额度类型的占用中台账(FIFO:按 grant_id 升序) */
List<CustomerCreditGrant> selectActiveGrantsForUpdate(@Param("customerId") Long customerId,
@Param("creditType") String creditType);
/** 新增台账 */
int insertGrant(CustomerCreditGrant grant);
/** 更新台账剩余余额/状态(乐观条件 remaining 不变,防并发核销) */
int updateGrantRemaining(CustomerCreditGrant grant);
}
@@ -0,0 +1,35 @@
package com.bms.customer.customermoney.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.customer.customermoney.domain.CustomerCreditApply;
/**
* 客户额度调整申请审批 Mapper
*
* @author bms
* @date 2026-09-16
*/
public interface CustomerCreditApplyMapper
{
/** 新增申请 */
int insertApply(CustomerCreditApply apply);
/** 按ID查申请(行锁,审批事务使用) */
CustomerCreditApply selectApplyByIdForUpdate(@Param("applyId") Long applyId);
/** 按ID查申请 */
CustomerCreditApply selectApplyById(@Param("applyId") Long applyId);
/** 查询申请列表(可按客户/状态/类型过滤) */
List<CustomerCreditApply> selectApplyList(CustomerCreditApply query);
/** 审批通过:PENDING -> APPROVED,回写账户ID、流水ID、审批人/时间/意见 */
int updateApproved(CustomerCreditApply apply);
/** 审批驳回:PENDING -> REJECTED */
int updateRejected(CustomerCreditApply apply);
/** 申请人撤销:PENDING -> CANCELLED */
int updateCancelled(@Param("applyId") Long applyId, @Param("updateBy") String updateBy);
}
@@ -0,0 +1,78 @@
package com.bms.customer.customermoney.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.customer.customermoney.domain.CustomerAccount;
import com.bms.customer.customermoney.domain.CustomerBalanceFlow;
import com.bms.customer.customermoney.domain.CustomerCreditFlow;
import com.bms.customer.customermoney.domain.CustomerReceipt;
/**
* 客户余额/收款/流水 Mapper
*
* @author bms
* @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);
/**
* 扫描授信已到期的CNY账户客户ID(信用或临时额度失效日早于今天;失效日当天仍有效)。
* 供定时任务批量物化失效使用,结果集不加锁,逐户处理时再行锁。
*/
List<Long> selectExpiredCreditCustomerIds();
/** 新增账户 */
int insertAccount(CustomerAccount account);
/** 乐观锁更新余额 */
int updateAccountBalance(CustomerAccount account);
/** 乐观锁更新额度(信用额度/临时额度/有效期) */
int updateAccountCredit(CustomerAccount account);
/* ================= 额度流水 ================= */
/** 查询额度流水 */
List<CustomerCreditFlow> selectCreditFlowList(CustomerCreditFlow query);
/** 新增额度流水 */
int insertCreditFlow(CustomerCreditFlow flow);
/* ================= 客户收款 ================= */
/** 查询收款单列表 */
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,50 @@
package com.bms.customer.customermoney.service;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.bms.customer.customermoney.domain.CustomerAccount;
import com.bms.customer.customermoney.domain.CustomerCreditGrant;
import com.bms.customer.customermoney.vo.RoleCreditQuotaVo;
/**
* 角色额度池 Service
*
* @author bms
* @date 2026-09-20
*/
public interface ICreditQuotaService
{
/** 全部角色额度池(管理员配置用) */
List<RoleCreditQuotaVo> listRoleQuotas();
/** 当前用户可使用的角色额度池(审批选池用;超管返回空列表由调用方放行) */
List<RoleCreditQuotaVo> myPools(Long userId);
/** 设置角色可分配额度总额(不能小于该池已分配额) */
void setRoleQuota(Long roleId, BigDecimal totalQuota, String remark);
/** 额度池占用台账查询 */
List<CustomerCreditGrant> listGrants(CustomerCreditGrant query);
/**
* 审批净增额度:占用角色额度池并写台账。
* roleId 为 null(超管审批)时台账角色为空、不占用任何池。
* 必须在客户账户行锁之后调用;内部对角色池加行锁防并发超配。
*/
void consume(CustomerAccount account, String creditType, BigDecimal amount,
Date effectiveDate, Date expireDate, Long roleId,
Long applyId, Long flowId, String username, Long userId);
/**
* 审批调减:按 FIFO 核销该客户该类型的占用台账,释放对应角色池。
* 历史无台账背书的额度(建档直配/超管授予)核销时不释放池。
* 返回实际从池中释放的金额(可能小于 amount)。
*/
BigDecimal releaseFifo(Long customerId, String creditType, BigDecimal amount, String username);
/**
* 到期自动失效:释放该客户该类型全部占用台账,返回释放总额。
*/
BigDecimal releaseAll(Long customerId, String creditType, String username);
}
@@ -0,0 +1,78 @@
package com.bms.customer.customermoney.service;
import java.util.List;
import com.bms.customer.customermoney.domain.CustomerAccount;
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;
/**
* 客户余额/收款/流水 Service接口
*
* @author bms
* @date 2026-09-11
*/
public interface ICustomerMoneyService
{
/** 查询客户各币种账户 */
List<CustomerAccount> selectAccountList(Long customerId);
/**
* 新建客户时初始化默认CNY账户(余额0,信用额度默认0),并写 INIT 额度流水。
* 已存在CNY账户时不重复创建。
*
* @param customerId 客户ID
* @return 1=已初始化或已存在
*/
int initDefaultCnyAccount(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);
/** 查询额度流水列表 */
List<CustomerCreditFlow> selectCreditFlowList(CustomerCreditFlow query);
/**
* 提交额度调整申请(信用额度/临时额度)。申请仅落库为 PENDING,不立即变动账户额度;
* 申请时会按账户当前额度做一次合法性校验(不能为负、不能低于已用额度),审批通过时会再次校验。
*/
int applyCreditAdjust(CustomerCreditApply apply);
/**
* 审批通过额度调整申请:PENDING -> APPROVED。
* 事务内重新锁定CNY账户并按审批时的实际额度计算/校验,通过后调整额度、回写客户档案、写额度流水。
* 净增额度需占用 approveRoleId 对应角色额度池(超管传 null 不占池;净减/到期释放不占池)。
*/
int approveCreditApply(Long applyId, String approveRemark, Long approveRoleId);
/** 审批驳回:PENDING -> REJECTED,驳回原因必填 */
int rejectCreditApply(Long applyId, String rejectReason);
/** 申请人撤销:PENDING -> CANCELLED */
int cancelCreditApply(Long applyId);
/** 查询额度调整申请列表 */
List<CustomerCreditApply> selectCreditApplyList(CustomerCreditApply query);
/** 查询额度调整申请详情 */
CustomerCreditApply selectCreditApplyById(Long applyId);
}
@@ -0,0 +1,296 @@
package com.bms.customer.customermoney.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.bms.common.core.domain.entity.SysRole;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.SecurityUtils;
import com.bms.customer.customermoney.domain.CustomerAccount;
import com.bms.customer.customermoney.domain.CustomerCreditGrant;
import com.bms.customer.customermoney.domain.SysRoleCreditQuota;
import com.bms.customer.customermoney.mapper.CreditQuotaMapper;
import com.bms.customer.customermoney.service.ICreditQuotaService;
import com.bms.customer.customermoney.vo.RoleCreditQuotaVo;
import com.bms.system.mapper.SysRoleMapper;
/**
* 角色额度池 Service实现
*
* @author bms
* @date 2026-09-20
*/
@Service
public class CreditQuotaServiceImpl implements ICreditQuotaService
{
private static final int MONEY_SCALE = 2;
@Autowired
private CreditQuotaMapper creditQuotaMapper;
@Autowired
private SysRoleMapper sysRoleMapper;
@Override
public List<RoleCreditQuotaVo> listRoleQuotas()
{
List<RoleCreditQuotaVo> list = creditQuotaMapper.selectRoleQuotaList();
list.forEach(this::fillAvailable);
return list;
}
@Override
public List<RoleCreditQuotaVo> myPools(Long userId)
{
if (userId == null)
{
throw new ServiceException("无法获取当前登录用户");
}
// 超管审批不占用任何角色额度池:直接返回空列表,前端不弹选池框(否则 LEFT JOIN 会带出
// 超管角色未配置的 0 额度池,导致前端强制选池并被"额度池剩余不足"拦截)
if (SecurityUtils.isAdmin(userId))
{
return java.util.Collections.emptyList();
}
List<RoleCreditQuotaVo> list = creditQuotaMapper.selectRoleQuotaListByUserId(userId);
list.forEach(this::fillAvailable);
return list;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setRoleQuota(Long roleId, BigDecimal totalQuota, String remark)
{
if (roleId == null)
{
throw new ServiceException("角色ID不能为空");
}
SysRole role = sysRoleMapper.selectRoleById(roleId);
if (role == null || "2".equals(role.getDelFlag()))
{
throw new ServiceException("角色不存在或已删除");
}
BigDecimal total = nz(totalQuota).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (total.compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("可分配额度总额不能为负数");
}
String username = SecurityUtils.getUsername();
SysRoleCreditQuota quota = creditQuotaMapper.selectQuotaForUpdate(roleId);
if (quota == null)
{
SysRoleCreditQuota newQuota = new SysRoleCreditQuota();
newQuota.setRoleId(roleId);
newQuota.setTotalQuota(total);
newQuota.setVersion(0);
newQuota.setCreateBy(username);
newQuota.setRemark(StringUtils.hasText(remark) ? remark.trim() : null);
creditQuotaMapper.insertQuota(newQuota);
return;
}
BigDecimal used = nz(creditQuotaMapper.sumActiveRemaining(roleId));
if (total.compareTo(used) < 0)
{
throw new ServiceException("可分配额度总额不能小于该角色已分配额度:" + used.toPlainString() + " CNY");
}
quota.setTotalQuota(total);
quota.setUpdateBy(username);
quota.setRemark(StringUtils.hasText(remark) ? remark.trim() : null);
if (creditQuotaMapper.updateQuota(quota) == 0)
{
throw new ServiceException("额度池已被其他操作变动,请刷新后重试");
}
}
@Override
public List<CustomerCreditGrant> listGrants(CustomerCreditGrant query)
{
return creditQuotaMapper.selectGrantList(query);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void consume(CustomerAccount account, String creditType, BigDecimal amount,
Date effectiveDate, Date expireDate, Long roleId,
Long applyId, Long flowId, String username, Long userId)
{
if (account == null || account.getId() == null)
{
throw new ServiceException("客户账户不存在,无法分配额度");
}
BigDecimal delta = nz(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (delta.compareTo(BigDecimal.ZERO) <= 0)
{
return;
}
CustomerCreditGrant grant = new CustomerCreditGrant();
grant.setCustomerId(account.getCustomerId());
grant.setAccountId(account.getId());
grant.setApproveUserId(userId);
grant.setApplyId(applyId);
grant.setFlowId(flowId);
grant.setCreditType(creditType);
grant.setAmount(delta);
grant.setRemaining(delta);
grant.setEffectiveDate(effectiveDate);
grant.setExpireDate(expireDate);
grant.setStatus(CustomerCreditGrant.STATUS_ACTIVE);
grant.setCreateBy(username);
if (roleId == null)
{
// 超管审批:不占用任何角色池
creditQuotaMapper.insertGrant(grant);
return;
}
// 校验角色归属与状态
SysRole role = sysRoleMapper.selectRoleById(roleId);
if (role == null || "2".equals(role.getDelFlag()) || !"0".equals(role.getStatus()))
{
throw new ServiceException("额度池角色不存在或已停用");
}
List<Long> myRoleIds = sysRoleMapper.selectRoleListByUserId(userId)
.stream().filter(id -> id != null && id.equals(roleId)).collect(Collectors.toList());
if (myRoleIds.isEmpty())
{
throw new ServiceException("不能使用不属于自己的角色额度池");
}
// 池行锁 -> 已分配实时汇总 -> 容量校验,防并发超配
SysRoleCreditQuota quota = creditQuotaMapper.selectQuotaForUpdate(roleId);
if (quota == null)
{
throw new ServiceException("角色【" + role.getRoleName() + "】未配置可分配额度总额,请联系管理员");
}
BigDecimal used = nz(creditQuotaMapper.sumActiveRemaining(roleId));
BigDecimal available = nz(quota.getTotalQuota()).subtract(used);
if (available.compareTo(delta) < 0)
{
throw new ServiceException("角色【" + role.getRoleName() + "】额度池不足:总额度 "
+ nz(quota.getTotalQuota()).toPlainString() + ",已分配 " + used.toPlainString()
+ ",剩余 " + available.toPlainString() + ",本次需分配 " + delta.toPlainString());
}
grant.setRoleId(roleId);
creditQuotaMapper.insertGrant(grant);
}
@Override
@Transactional(rollbackFor = Exception.class)
public BigDecimal releaseFifo(Long customerId, String creditType, BigDecimal amount, String username)
{
BigDecimal need = nz(amount);
if (need.compareTo(BigDecimal.ZERO) <= 0)
{
return BigDecimal.ZERO;
}
List<CustomerCreditGrant> grants = creditQuotaMapper.selectActiveGrantsForUpdate(customerId, creditType);
if (grants.isEmpty())
{
return BigDecimal.ZERO;
}
lockQuotaRows(grants);
BigDecimal released = BigDecimal.ZERO;
Date now = new Date();
for (CustomerCreditGrant grant : grants)
{
if (need.compareTo(BigDecimal.ZERO) <= 0)
{
break;
}
BigDecimal remaining = nz(grant.getRemaining());
if (remaining.compareTo(BigDecimal.ZERO) <= 0)
{
continue;
}
BigDecimal take = remaining.min(need);
BigDecimal after = remaining.subtract(take).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
grant.setRemaining(after);
grant.setUpdateBy(username);
if (after.compareTo(BigDecimal.ZERO) == 0)
{
grant.setStatus(CustomerCreditGrant.STATUS_RELEASED);
grant.setReleaseTime(now);
}
else
{
grant.setStatus(CustomerCreditGrant.STATUS_ACTIVE);
grant.setReleaseTime(null);
}
creditQuotaMapper.updateGrantRemaining(grant);
need = need.subtract(take);
released = released.add(take);
}
return released.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
@Override
@Transactional(rollbackFor = Exception.class)
public BigDecimal releaseAll(Long customerId, String creditType, String username)
{
List<CustomerCreditGrant> grants = creditQuotaMapper.selectActiveGrantsForUpdate(customerId, creditType);
if (grants.isEmpty())
{
return BigDecimal.ZERO;
}
lockQuotaRows(grants);
BigDecimal released = BigDecimal.ZERO;
Date now = new Date();
for (CustomerCreditGrant grant : grants)
{
BigDecimal remaining = nz(grant.getRemaining());
if (remaining.compareTo(BigDecimal.ZERO) <= 0)
{
continue;
}
grant.setRemaining(BigDecimal.ZERO);
grant.setStatus(CustomerCreditGrant.STATUS_RELEASED);
grant.setReleaseTime(now);
grant.setUpdateBy(username);
creditQuotaMapper.updateGrantRemaining(grant);
released = released.add(remaining);
}
return released.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
/**
* 对台账涉及的非空角色池按 role_id 升序加行锁,
* 保证与池占用(consume)互斥且避免多角色交叉死锁。
*/
private void lockQuotaRows(List<CustomerCreditGrant> grants)
{
List<Long> roleIds = grants.stream()
.map(CustomerCreditGrant::getRoleId)
.filter(id -> id != null)
.distinct()
.sorted(Comparator.naturalOrder())
.collect(Collectors.toList());
for (Long roleId : roleIds)
{
// 池配置理论上一定存在(占用时已校验);若被误删则空过,不影响台账核销
creditQuotaMapper.selectQuotaForUpdate(roleId);
}
}
private void fillAvailable(RoleCreditQuotaVo vo)
{
BigDecimal total = nz(vo.getTotalQuota());
BigDecimal used = nz(vo.getUsedQuota());
vo.setTotalQuota(total);
vo.setUsedQuota(used);
vo.setAvailableQuota(total.subtract(used).setScale(MONEY_SCALE, RoundingMode.HALF_UP));
}
private BigDecimal nz(BigDecimal value)
{
return value == null ? BigDecimal.ZERO : value;
}
}
@@ -0,0 +1,830 @@
package com.bms.customer.customermoney.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
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.bms.common.exception.ServiceException;
import com.bms.common.utils.SecurityUtils;
import com.bms.ar.domain.ArBankAccount;
import com.bms.ar.mapper.ArBankAccountMapper;
import com.bms.customer.customerinfo.domain.BmsCustomer;
import com.bms.customer.customerinfo.domain.BmsCustomerAttachment;
import com.bms.customer.customerinfo.mapper.BmsCustomerMapper;
import com.bms.customer.customermoney.api.BalanceChangeCommand;
import com.bms.customer.customermoney.api.CustomerFundBizType;
import com.bms.customer.customermoney.api.ICustomerAccountApi;
import com.bms.customer.customermoney.domain.CustomerAccount;
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.mapper.CustomerCreditApplyMapper;
import com.bms.customer.customermoney.mapper.CustomerMoneyMapper;
import com.bms.customer.customermoney.service.ICreditQuotaService;
import com.bms.customer.customermoney.service.ICustomerMoneyService;
import com.bms.price.service.IPrcOrderBillService;
import com.bms.system.domain.BmsExchangeRate;
import com.bms.system.service.IBmsExchangeRateService;
/**
* 客户余额/收款/流水 Service实现
*
* @author bms
* @date 2026-09-11
*/
@Service
public class CustomerMoneyServiceImpl implements ICustomerMoneyService
{
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
/** 本位币币种 */
private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY;
/** 金额保留位数 */
private static final int MONEY_SCALE = 2;
@Autowired
private CustomerMoneyMapper customerMoneyMapper;
@Autowired
private CustomerCreditApplyMapper creditApplyMapper;
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
@Autowired
private IPrcOrderBillService orderBillService;
@Autowired
private IBmsExchangeRateService exchangeRateService;
/** 客户资金公共服务:账户/余额/授信额度变动统一收口 */
@Autowired
private ICustomerAccountApi customerAccountApi;
/** 角色额度池:审批增量占用/调减与到期释放 */
@Autowired
private ICreditQuotaService creditQuotaService;
/** 公司收款银行账户(登记收款选择并快照) */
@Autowired
private ArBankAccountMapper arBankAccountMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public List<CustomerAccount> selectAccountList(Long customerId)
{
List<CustomerAccount> list = customerMoneyMapper.selectAccountListByCustomerId(customerId);
// 查询时顺带处理授信到期自动失效(仅CNY账户),保证卡片展示为最新值
for (int i = 0; i < list.size(); i++)
{
CustomerAccount account = list.get(i);
if (BASE_CURRENCY.equals(account.getCurrency())
&& (isTempCreditExpired(account) || isCreditExpired(account)))
{
CustomerAccount locked = customerAccountApi.lockCnyAccount(customerId);
customerAccountApi.expireDueCreditsIfNeeded(locked);
if (locked != null)
{
list.set(i, locked);
}
}
}
return list;
}
@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.setBizType(BmsCustomerAttachment.BIZ_TYPE_CUSTOMER_RECEIPT);
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(BASE_CURRENCY);
}
else
{
receipt.setCurrency(receipt.getCurrency().trim().toUpperCase());
}
// 收款银行账户:以库中启用账户为准回填快照(不信任前端传入的户名/开户行文本),
// 账户后续停用或修改不影响历史单据展示。
// 未选账户时,允许只手工录入客户银行账号文本(bankAccountNo),户名/开户行清空。
if (receipt.getBankAccountId() != null)
{
ArBankAccount bankAccount = arBankAccountMapper.selectEnabledById(receipt.getBankAccountId());
if (bankAccount == null)
{
throw new ServiceException("收款银行账户不存在或已停用,请重新选择");
}
receipt.setBankAccountName(bankAccount.getAccountName());
receipt.setBankAccountNo(bankAccount.getAccountNo());
receipt.setBankName(bankAccount.getBankName());
}
else
{
receipt.setBankAccountName(null);
receipt.setBankName(null);
receipt.setBankAccountNo(StringUtils.hasText(receipt.getBankAccountNo())
? receipt.getBankAccountNo().trim() : null);
}
// 汇率与本位币金额:CNY 固定 1:1;外币以收款日即期汇率表为基准,允许页面手工修改。
// 汇率来源由后端权威判定(不信任前端标记):与汇率表一致=RATE_TABLE,不一致/表未配置=MANUAL。
BigDecimal rate = receipt.getExchangeRate();
if (BASE_CURRENCY.equals(receipt.getCurrency()))
{
rate = BigDecimal.ONE;
receipt.setRateSource(CustomerReceipt.RATE_SOURCE_SYSTEM);
receipt.setRateSourceDesc("系统默认(本位币1:1)");
}
else
{
Date bizDate = receipt.getReceivedAt() != null ? receipt.getReceivedAt() : new Date();
BmsExchangeRate tableRate = null;
try
{
tableRate = exchangeRateService.resolveRate(
receipt.getCurrency(), BASE_CURRENCY, BmsExchangeRate.RATE_TYPE_SPOT, bizDate);
}
catch (ServiceException ex)
{
// 汇率表未配置:若页面手工录了有效汇率则放行,否则继续走下面的非空校验拦截
tableRate = null;
}
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0)
{
if (tableRate == null)
{
throw new ServiceException("汇率表未维护【" + receipt.getCurrency()
+ " → CNY】收款日生效的即期汇率,请手工录入汇率");
}
rate = tableRate.getExchangeRate();
}
if (rate.compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("汇率必须大于0,请检查录入的汇率");
}
rate = rate.setScale(8, RoundingMode.HALF_UP);
// 与汇率表汇率一致(8位精度)即认定为汇率表来源,否则为手工修改
if (tableRate != null
&& rate.compareTo(tableRate.getExchangeRate().setScale(8, RoundingMode.HALF_UP)) == 0)
{
receipt.setRateSource(CustomerReceipt.RATE_SOURCE_RATE_TABLE);
receipt.setRateSourceDesc(buildRateTableSourceDesc(tableRate));
}
else
{
receipt.setRateSource(CustomerReceipt.RATE_SOURCE_MANUAL);
if (tableRate != null)
{
receipt.setRateSourceDesc("手工修改(汇率表即期汇率 "
+ tableRate.getExchangeRate().setScale(8, RoundingMode.HALF_UP).toPlainString() + ")");
}
else
{
receipt.setRateSourceDesc("手工录入(汇率表未维护该币种收款日汇率)");
}
}
}
receipt.setExchangeRate(rate.setScale(8, RoundingMode.HALF_UP));
BigDecimal baseAmount = receipt.getAmount().multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
receipt.setBaseAmount(baseAmount);
receipt.setReceiptNo(generateNo("SK"));
receipt.setStatus(CustomerReceipt.STATUS_PENDING);
receipt.setAllocatedAmount(BigDecimal.ZERO);
// 核销口径统一为本位币 CNY
receipt.setUnallocatedAmount(baseAmount);
if (!StringUtils.hasText(receipt.getInvoiceStatus()))
{
receipt.setInvoiceStatus("NONE");
}
receipt.setVersion(0);
customerMoneyMapper.insertReceipt(receipt);
// 收款附件:复用客户附件表,业务模块挂 customer_receipt,biz_id 关联收款单
saveReceiptAttachments(receipt);
return 1;
}
/**
* 构造汇率表来源说明:汇率来源(中国银行/央行等)+ 命中路径(正向/反向取倒数/经CNY交叉)。
* resolveRate 返回对象的 remark 带 __RATE_PATH__ 前缀标记命中路径。
*/
private String buildRateTableSourceDesc(BmsExchangeRate tableRate)
{
String source = StringUtils.hasText(tableRate.getSource()) ? tableRate.getSource() : "汇率表";
String path = "DIRECT";
String remark = tableRate.getRemark();
if (StringUtils.hasText(remark) && remark.startsWith("__RATE_PATH__"))
{
path = remark.substring("__RATE_PATH__".length());
}
String pathText;
switch (path)
{
case "REVERSE":
pathText = "反向配置取倒数";
break;
case "CROSS":
pathText = "经CNY交叉换算";
break;
case "SAME":
pathText = "同币种";
break;
case "DIRECT":
default:
pathText = "正向配置";
break;
}
return "汇率表·" + source + "(" + pathText + ")";
}
@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().trim().toUpperCase()
: BASE_CURRENCY;
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
// 本位币入账金额(兼容历史数据:未折算时按原金额/汇率1处理)
BigDecimal rate = receipt.getExchangeRate() != null && receipt.getExchangeRate().compareTo(BigDecimal.ZERO) > 0
? receipt.getExchangeRate() : BigDecimal.ONE;
BigDecimal originalAmount = nz(receipt.getAmount());
BigDecimal baseAmount = receipt.getBaseAmount() != null
? receipt.getBaseAmount()
: originalAmount.multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
// 1. 本位币入账(公共资金服务内完成:锁户/初始化 -> 余额变动 -> 余额流水)。
// 额度占用统一以账单为准:收款审核只增加余额,核销账单时才释放已用额度
BalanceChangeCommand fundCmd = new BalanceChangeCommand();
fundCmd.setCustomerId(receipt.getCustomerId());
// 流水保留收款原币;无论收什么币种,一律锁CNY账户折算入账
fundCmd.setCurrency(currency);
fundCmd.setAccountCurrency(BASE_CURRENCY);
fundCmd.setAmount(originalAmount);
fundCmd.setExchangeRate(rate);
fundCmd.setBaseAmount(baseAmount);
fundCmd.setBizType(CustomerFundBizType.RECEIPT);
fundCmd.setBizId(receipt.getId());
fundCmd.setBizNo(receipt.getReceiptNo());
fundCmd.setReoccupyUsedCredit(false);
fundCmd.setRemark("收款单审核通过:" + receipt.getReceiptNo());
customerAccountApi.refundBalance(fundCmd);
// 余额入账后:按 FIFO 清除订单欠费状态(ARREARS -> NORMAL)
BmsCustomer customer = bmsCustomerMapper.selectBmsCustomerByCustomerId(receipt.getCustomerId());
if (customer != null && StringUtils.hasText(customer.getCustomerCode())
&& baseAmount != null && baseAmount.compareTo(BigDecimal.ZERO) > 0)
{
orderBillService.clearCreditArrearsByAmount(customer.getCustomerCode().trim(), baseAmount);
}
// 2. 收款单置为已确认
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);
}
@Override
public List<CustomerCreditFlow> selectCreditFlowList(CustomerCreditFlow query)
{
return customerMoneyMapper.selectCreditFlowList(query);
}
/**
* 执行额度调整(额度申请审批通过后调用)。
* 锁CNY账户 -> 按审批时实际额度计算并校验 -> 乐观锁更新 -> 回写客户档案 -> 写额度流水 -> 角色额度池占用/释放。
* roleId 为本次审批选择的额度池角色(超管/净减时可为null);applyId 为审批申请单ID。
* 返回已落库的额度流水(含ID)。
*/
private CustomerCreditFlow executeAdjustCredit(CustomerCreditFlow cmd, Long roleId, Long applyId)
{
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
Long customerId = cmd.getCustomerId();
String creditType = cmd.getCreditType();
String adjustMode = cmd.getAdjustMode();
String reason = cmd.getReason();
boolean isTemp = CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType);
// 1. 参数/日期/结果合法性校验并锁定CNY账户(不存在则按客户档案额度初始化);先处理临时额度过期
CustomerAccount account = validateAdjustCommand(cmd, username);
BigDecimal input = nz(cmd.getChangeAmount());
// 2. 计算调整结果
BigDecimal before = isTemp ? nz(account.getExtraCredit()) : nz(account.getTotalCredit());
BigDecimal after;
switch (adjustMode)
{
case CustomerCreditFlow.MODE_SET:
after = input;
break;
case CustomerCreditFlow.MODE_ADD:
after = before.add(input);
break;
default:
after = before.subtract(input);
break;
}
after = after.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
// 3. 有效期处理:信用/临时同口径——不允许预约未来生效;信用额度失效日期必填,临时额度空=长期
Date effectiveDate = null;
Date expireDate = null;
LocalDate today = LocalDate.now();
LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today;
effectiveDate = toSqlDate(eff);
if (cmd.getExpireDate() != null)
{
expireDate = toSqlDate(toLocalDate(cmd.getExpireDate()));
}
if (isTemp)
{
account.setExtraCredit(after);
account.setExtraEffectiveDate(effectiveDate);
account.setExtraExpireDate(expireDate);
}
else
{
account.setTotalCredit(after);
account.setCreditEffectiveDate(effectiveDate);
account.setCreditExpireDate(expireDate);
}
account.setUpdateBy(username);
// 4. 乐观锁更新额度
int rows = customerMoneyMapper.updateAccountCredit(account);
if (rows == 0)
{
throw new ServiceException("账户额度已被其他操作变动,请刷新后重试");
}
account.setVersion(account.getVersion() + 1);
// 5. 回写客户档案额度字段(额度唯一数据源为账户,档案仅做同步展示)
bmsCustomerMapper.updateCustomerCredit(customerId,
nz(account.getTotalCredit()), nz(account.getExtraCredit()), username);
// 6. 写额度流水
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(customerId);
flow.setAccountId(account.getId());
flow.setCreditType(creditType);
flow.setChangeType(CustomerCreditFlow.CHANGE_ADJUST);
int direction = after.compareTo(before) > 0 ? 1 : -1;
flow.setDirection(direction);
flow.setBeforeAmount(before);
flow.setChangeAmount(after.subtract(before).abs());
flow.setAfterAmount(after);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(effectiveDate);
flow.setExpireDate(expireDate);
flow.setReason(reason);
flow.setAdjustMode(adjustMode);
flow.setOperatorId(userId);
// 非超管审批且未指定角色池的净增申请不允许通过(在 consume 中强校验)
flow.setRoleId(SecurityUtils.isAdmin(userId) ? null : roleId);
flow.setCreateBy(username);
flow.setRemark(cmd.getRemark());
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
// 7. 角色额度池占用/释放(账户行锁已持有;池锁在台账服务内部按角色加锁)
BigDecimal netDelta = after.subtract(before);
if (netDelta.compareTo(BigDecimal.ZERO) > 0)
{
Long poolRoleId = SecurityUtils.isAdmin(userId) ? null : roleId;
if (poolRoleId == null && !SecurityUtils.isAdmin(userId))
{
throw new ServiceException("请选择本次额度分配占用的角色额度池");
}
creditQuotaService.consume(account, creditType, netDelta,
effectiveDate, expireDate, poolRoleId, applyId, flow.getId(), username, userId);
}
else if (netDelta.compareTo(BigDecimal.ZERO) < 0)
{
creditQuotaService.releaseFifo(customerId, creditType, netDelta.negate(), username);
}
return flow;
}
/* ================= 额度调整:申请 / 审批 ================= */
@Override
public List<CustomerCreditApply> selectCreditApplyList(CustomerCreditApply query)
{
return creditApplyMapper.selectApplyList(query);
}
@Override
public CustomerCreditApply selectCreditApplyById(Long applyId)
{
return creditApplyMapper.selectApplyById(applyId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int applyCreditAdjust(CustomerCreditApply apply)
{
if (apply == null || apply.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
// 构造内部调整命令,复用统一的参数/日期/额度合法性校验(只校验,不写入)
CustomerCreditFlow cmd = new CustomerCreditFlow();
cmd.setCustomerId(apply.getCustomerId());
cmd.setCreditType(apply.getCreditType());
cmd.setAdjustMode(apply.getAdjustMode());
cmd.setChangeAmount(nz(apply.getChangeAmount()));
cmd.setReason(apply.getReason());
cmd.setRemark(apply.getRemark());
cmd.setEffectiveDate(apply.getEffectiveDate());
cmd.setExpireDate(apply.getExpireDate());
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
// 1. 参数校验 + 日期校验 + 按当前账户额度预演结果,提前拦截不可能通过的申请(不写入账户)
validateAdjustCommand(cmd, username);
apply.setChangeAmount(cmd.getChangeAmount());
apply.setReason(cmd.getReason());
// 2. 待审批申请落库,不动账户额度
apply.setApplyNo(generateNo("EDSQ"));
apply.setStatus(CustomerCreditApply.STATUS_PENDING);
apply.setApplyBy(username);
apply.setApplyUserId(userId);
apply.setApplyTime(new Date());
apply.setCreateBy(username);
return creditApplyMapper.insertApply(apply);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int approveCreditApply(Long applyId, String approveRemark, Long approveRoleId)
{
if (applyId == null)
{
throw new ServiceException("申请单ID不能为空");
}
CustomerCreditApply apply = creditApplyMapper.selectApplyByIdForUpdate(applyId);
if (apply == null)
{
throw new ServiceException("额度调整申请不存在");
}
if (!CustomerCreditApply.STATUS_PENDING.equals(apply.getStatus()))
{
throw new ServiceException("该申请已处理,不能重复审批");
}
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
// 以审批时账户的实际额度重新计算并调整(申请期间额度可能已变动)
CustomerCreditFlow cmd = new CustomerCreditFlow();
cmd.setCustomerId(apply.getCustomerId());
cmd.setCreditType(apply.getCreditType());
cmd.setAdjustMode(apply.getAdjustMode());
cmd.setChangeAmount(nz(apply.getChangeAmount()));
cmd.setReason(apply.getReason());
cmd.setRemark(apply.getRemark());
cmd.setEffectiveDate(apply.getEffectiveDate());
cmd.setExpireDate(apply.getExpireDate());
CustomerCreditFlow flow = executeAdjustCredit(cmd, approveRoleId, applyId);
CustomerCreditApply update = new CustomerCreditApply();
update.setApplyId(applyId);
update.setAccountId(flow.getAccountId());
update.setFlowId(flow.getId());
update.setApproveBy(username);
update.setApproveUserId(userId);
// 超管审批不占池;非超管净减时 roleId 为 null 也正常(只释放不占用)
update.setApproveRoleId(SecurityUtils.isAdmin(userId) ? null : approveRoleId);
update.setApproveRemark(approveRemark);
int rows = creditApplyMapper.updateApproved(update);
if (rows == 0)
{
throw new ServiceException("申请状态已变化,审批失败");
}
return rows;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int rejectCreditApply(Long applyId, String rejectReason)
{
if (applyId == null)
{
throw new ServiceException("申请单ID不能为空");
}
if (!StringUtils.hasText(rejectReason))
{
throw new ServiceException("请填写驳回原因");
}
String reason = rejectReason.trim();
if (reason.length() > 255)
{
throw new ServiceException("驳回原因不能超过255个字");
}
CustomerCreditApply apply = creditApplyMapper.selectApplyByIdForUpdate(applyId);
if (apply == null)
{
throw new ServiceException("额度调整申请不存在");
}
if (!CustomerCreditApply.STATUS_PENDING.equals(apply.getStatus()))
{
throw new ServiceException("该申请已处理,不能重复审批");
}
CustomerCreditApply update = new CustomerCreditApply();
update.setApplyId(applyId);
update.setApproveBy(SecurityUtils.getUsername());
update.setApproveUserId(SecurityUtils.getUserId());
update.setApproveRemark(reason);
int rows = creditApplyMapper.updateRejected(update);
if (rows == 0)
{
throw new ServiceException("申请状态已变化,驳回失败");
}
return rows;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int cancelCreditApply(Long applyId)
{
if (applyId == null)
{
throw new ServiceException("申请单ID不能为空");
}
CustomerCreditApply apply = creditApplyMapper.selectApplyByIdForUpdate(applyId);
if (apply == null)
{
throw new ServiceException("额度调整申请不存在");
}
if (!CustomerCreditApply.STATUS_PENDING.equals(apply.getStatus()))
{
throw new ServiceException("仅待审批的申请可以撤销");
}
int rows = creditApplyMapper.updateCancelled(applyId, SecurityUtils.getUsername());
if (rows == 0)
{
throw new ServiceException("申请状态已变化,撤销失败");
}
return rows;
}
/**
* 额度调整参数/日期/结果合法性校验(申请与审批共用)。
* 锁定CNY账户做预演但不写入,返回当前账户;规整后的金额/原因/日期回写到 cmd。
*/
private CustomerAccount validateAdjustCommand(CustomerCreditFlow cmd, String username)
{
if (cmd == null || cmd.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
String creditType = cmd.getCreditType();
if (!CustomerCreditFlow.CREDIT_TYPE_CREDIT.equals(creditType)
&& !CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType))
{
throw new ServiceException("额度类型不正确");
}
String adjustMode = cmd.getAdjustMode();
if (!CustomerCreditFlow.MODE_SET.equals(adjustMode)
&& !CustomerCreditFlow.MODE_ADD.equals(adjustMode)
&& !CustomerCreditFlow.MODE_SUB.equals(adjustMode))
{
throw new ServiceException("调整方式不正确");
}
BigDecimal input = nz(cmd.getChangeAmount());
if (input.compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("调整金额必须大于0");
}
if (!StringUtils.hasText(cmd.getReason()))
{
throw new ServiceException("请填写调整原因");
}
String reason = cmd.getReason().trim();
if (reason.length() > 255)
{
throw new ServiceException("调整原因不能超过255个字");
}
cmd.setChangeAmount(input);
cmd.setReason(reason);
boolean isTemp = CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType);
CustomerAccount account = customerAccountApi.lockCnyAccount(cmd.getCustomerId());
customerAccountApi.expireDueCreditsIfNeeded(account);
BigDecimal before = isTemp ? nz(account.getExtraCredit()) : nz(account.getTotalCredit());
BigDecimal after;
switch (adjustMode)
{
case CustomerCreditFlow.MODE_SET:
after = input;
break;
case CustomerCreditFlow.MODE_ADD:
after = before.add(input);
break;
default:
after = before.subtract(input);
break;
}
after = after.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (after.compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("调整后额度不能为负数");
}
// 口径变更(2026-09-23):计费不再变动 used_credit,额度调整无需校验已用额度
if (after.compareTo(before) == 0)
{
throw new ServiceException("额度未发生变化,无需调整");
}
if (!isTemp && cmd.getExpireDate() == null)
{
// 信用额度必须设置失效日期(到期自动清零);临时额度不填=长期
throw new ServiceException("请选择信用额度失效日期");
}
LocalDate today = LocalDate.now();
LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today;
if (eff.isAfter(today))
{
throw new ServiceException("生效日期不能晚于今天,需要未来生效请在生效当天再提交");
}
if (cmd.getExpireDate() != null)
{
LocalDate exp = toLocalDate(cmd.getExpireDate());
if (!exp.isAfter(eff))
{
throw new ServiceException("失效日期必须晚于生效日期");
}
}
return account;
}
/* ================= 额度私有方法 ================= */
/**
* 新建客户时默认初始化CNY账户(委托公共资金服务统一收口)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int initDefaultCnyAccount(Long customerId)
{
return customerAccountApi.initDefaultCnyAccount(customerId);
}
/** 临时额度是否已过期:有失效日期且今天已晚于失效日(失效日当天仍有效)。仅只读判断,不落库 */
private boolean isTempCreditExpired(CustomerAccount account)
{
return account != null
&& nz(account.getExtraCredit()).compareTo(BigDecimal.ZERO) > 0
&& account.getExtraExpireDate() != null
&& LocalDate.now().isAfter(toLocalDate(account.getExtraExpireDate()));
}
/** 信用额度是否已过期:口径同临时额度(失效日当天仍有效) */
private boolean isCreditExpired(CustomerAccount account)
{
return account != null
&& nz(account.getTotalCredit()).compareTo(BigDecimal.ZERO) > 0
&& account.getCreditExpireDate() != null
&& LocalDate.now().isAfter(toLocalDate(account.getCreditExpireDate()));
}
private LocalDate toLocalDate(Date date)
{
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
}
private Date toSqlDate(LocalDate localDate)
{
return java.sql.Date.valueOf(localDate);
}
/* ================= 私有方法 ================= */
/** 保存收款附件到客户附件表 */
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.setBizType(BmsCustomerAttachment.BIZ_TYPE_CUSTOMER_RECEIPT);
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;
}
}
@@ -0,0 +1,44 @@
package com.bms.customer.customermoney.task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.bms.customer.customermoney.api.ICustomerAccountApi;
/**
* 客户授信(信用额度/临时额度)到期自动失效任务。
*
* <p>扫描授信已到期(信用或临时额度&gt;0 且失效日期早于今天)的CNY账户,
* 逐户独立事务物化失效:额度清零 -> 释放角色额度池 -> 回写客户档案 -> 写 EXPIRE 额度流水。
* 失效日当天额度仍有效,故严格按「早于今天」判断。</p>
*
* <p>本任务纳入 Bms 定时任务菜单(Quartz / sys_job)统一管理:
* 调用目标为 {@code customerCreditTask.expireCredits()},cron、启停均在
* 「系统监控 - 定时任务」中配置,不再使用 @Scheduled 硬编码。
* 除定时调度外,资金接口访问时仍有惰性失效(即查即失效)兜底。</p>
*
* <p>到期不影响已用额度:已确认账单形成的授信占用仍然有效,客户回款核销后
* used_credit 自然下降;可用额度可能短暂为负,新计费开单会被额度校验拦截。</p>
*
* @author bms
* @date 2026-09-17
*/
@Component("customerCreditTask")
public class TempCreditExpireTask
{
private static final Logger log = LoggerFactory.getLogger(TempCreditExpireTask.class);
@Autowired
private ICustomerAccountApi customerAccountApi;
/**
* 授信到期失效(sys_job 调用目标:customerCreditTask.expireCredits())。
* 异常直接抛出,由 Quartz 框架记录任务执行失败日志。
*/
public void expireCredits()
{
int count = customerAccountApi.expireAllDueCredits();
log.info("客户授信到期失效任务执行完成,失效账户数={}", count);
}
}
@@ -0,0 +1,97 @@
package com.bms.customer.customermoney.vo;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 客户账号状态查询VO(收货扣件准入控制用)。
*
* <p>只读轻量,不锁账户也不物化临时额度失效:临时额度已过期时按"视为0"计算可用额度,
* 但 DB 保持原状——由定时任务或后续真实资金入口落库。</p>
*
* <p>欠费定义:availableCredit &lt; 0(可用额度 = 账户余额 + 信用额度 + 有效临时额度)。</p>
*
* @author bms
* @date 2026-09-17
*/
public class CustomerAccountStatusVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 综合状态:正常 */
public static final String STATUS_NORMAL = "NORMAL";
/** 综合状态:停用 */
public static final String STATUS_DISABLED = "DISABLED";
/** 综合状态:欠费(可用额度不足) */
public static final String STATUS_OVERDUE = "OVERDUE";
/** 综合状态:停用且欠费 */
public static final String STATUS_DISABLED_AND_OVERDUE = "DISABLED_AND_OVERDUE";
/** 客户ID */
private Long customerId;
/** 客户编码 */
private String customerCode;
/** 客户名称 */
private String customerName;
/** 启用状态(0正常 1停用)—— 来自 ctr_customer.is_enable */
private String isEnable;
/** 启用状态文案(正常 / 停用) */
private String isEnableLabel;
/** CNY账户余额 */
private BigDecimal balance;
/** CNY账户冻结余额 */
private BigDecimal frozenBalance;
/** 授信总额度(CNY) */
private BigDecimal totalCredit;
/** 授信总额度实际有效值(过期未物化时为0,接口计算得出,不物化DB) */
private BigDecimal effectiveTotalCredit;
/** 临时额度原值(CNY,DB 中 extra_credit 字段) */
private BigDecimal extraCredit;
/** 临时额度实际有效值(过期时为0,接口计算得出,不物化DB) */
private BigDecimal effectiveExtraCredit;
/** 可用额度(账户余额 + 授信总额度 + 有效临时额度;可能为负表示欠费) */
private BigDecimal availableCredit;
/** 是否欠费(可用额度 < 0) */
private boolean overdue;
/** 是否存在过期未物化的临时额度(仅作提示,不影响判定) */
private boolean expiredTempCreditPending;
/** 综合状态:NORMAL / DISABLED / OVERDUE / DISABLED_AND_OVERDUE */
private String accountStatus;
/** 综合状态说明 */
private String accountStatusDesc;
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public String getCustomerCode() { return customerCode; }
public void setCustomerCode(String customerCode) { this.customerCode = customerCode; }
public String getCustomerName() { return customerName; }
public void setCustomerName(String customerName) { this.customerName = customerName; }
public String getIsEnable() { return isEnable; }
public void setIsEnable(String isEnable) { this.isEnable = isEnable; }
public String getIsEnableLabel() { return isEnableLabel; }
public void setIsEnableLabel(String isEnableLabel) { this.isEnableLabel = isEnableLabel; }
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; }
public BigDecimal getFrozenBalance() { return frozenBalance; }
public void setFrozenBalance(BigDecimal frozenBalance) { this.frozenBalance = frozenBalance; }
public BigDecimal getTotalCredit() { return totalCredit; }
public void setTotalCredit(BigDecimal totalCredit) { this.totalCredit = totalCredit; }
public BigDecimal getEffectiveTotalCredit() { return effectiveTotalCredit; }
public void setEffectiveTotalCredit(BigDecimal effectiveTotalCredit) { this.effectiveTotalCredit = effectiveTotalCredit; }
public BigDecimal getExtraCredit() { return extraCredit; }
public void setExtraCredit(BigDecimal extraCredit) { this.extraCredit = extraCredit; }
public BigDecimal getEffectiveExtraCredit() { return effectiveExtraCredit; }
public void setEffectiveExtraCredit(BigDecimal effectiveExtraCredit) { this.effectiveExtraCredit = effectiveExtraCredit; }
public BigDecimal getAvailableCredit() { return availableCredit; }
public void setAvailableCredit(BigDecimal availableCredit) { this.availableCredit = availableCredit; }
public boolean isOverdue() { return overdue; }
public void setOverdue(boolean overdue) { this.overdue = overdue; }
public boolean isExpiredTempCreditPending() { return expiredTempCreditPending; }
public void setExpiredTempCreditPending(boolean expiredTempCreditPending) { this.expiredTempCreditPending = expiredTempCreditPending; }
public String getAccountStatus() { return accountStatus; }
public void setAccountStatus(String accountStatus) { this.accountStatus = accountStatus; }
public String getAccountStatusDesc() { return accountStatusDesc; }
public void setAccountStatusDesc(String accountStatusDesc) { this.accountStatusDesc = accountStatusDesc; }
}
@@ -0,0 +1,91 @@
package com.bms.customer.customermoney.vo;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import com.bms.common.annotation.Excel;
import com.bms.customer.customermoney.domain.CustomerBalanceFlow;
import lombok.Data;
/**
* 客户余额流水导出 VO(列与页面余额流水 tab 对齐,枚举导出为中文文案)
*
* @author bms
* @date 2026-09-21
*/
@Data
public class CustomerBalanceFlowExportVO implements Serializable
{
private static final long serialVersionUID = 1L;
@Excel(name = "流水号", width = 22)
private String flowNo;
@Excel(name = "时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@Excel(name = "业务类型", width = 12)
private String bizTypeLabel;
@Excel(name = "收支", width = 8)
private String directionLabel;
@Excel(name = "原币金额", width = 16)
private String amountText;
@Excel(name = "汇率", width = 12)
private String exchangeRate;
@Excel(name = "本位币变动(CNY)", width = 18)
private BigDecimal baseChangeAmount;
@Excel(name = "变动前余额", width = 14)
private BigDecimal beforeBalance;
@Excel(name = "变动后余额", width = 14)
private BigDecimal afterBalance;
@Excel(name = "关联单号", width = 22)
private String bizNo;
@Excel(name = "操作人", width = 12)
private String createBy;
@Excel(name = "备注", width = 30)
private String remark;
public static CustomerBalanceFlowExportVO from(CustomerBalanceFlow f)
{
CustomerBalanceFlowExportVO vo = new CustomerBalanceFlowExportVO();
vo.setFlowNo(f.getFlowNo());
vo.setCreateTime(f.getCreateTime());
vo.setBizTypeLabel(bizTypeLabel(f.getBizType()));
boolean income = f.getDirection() != null && f.getDirection() == 1;
vo.setDirectionLabel(income ? "收入" : "支出");
String amt = f.getAmount() == null ? "" : f.getAmount().stripTrailingZeros().toPlainString();
vo.setAmountText((f.getCurrency() == null ? "" : f.getCurrency() + " ") + amt);
vo.setExchangeRate(f.getExchangeRate() == null ? null : f.getExchangeRate().stripTrailingZeros().toPlainString());
BigDecimal base = f.getBaseAmount() != null ? f.getBaseAmount() : f.getAmount();
// 支出导出为负数(配合“收支”列阅读,同时保持 Excel 数值类型)
vo.setBaseChangeAmount(base == null ? null : (income ? base : base.negate()));
vo.setBeforeBalance(f.getBeforeBalance());
vo.setAfterBalance(f.getAfterBalance());
vo.setBizNo(f.getBizNo());
vo.setCreateBy(f.getCreateBy());
vo.setRemark(f.getRemark());
return vo;
}
private static String bizTypeLabel(String v)
{
if ("RECEIPT".equals(v)) return "收款";
if ("AR_BILL".equals(v)) return "应收账单";
if ("BILL_PAY".equals(v)) return "账单核销";
if ("ORDER".equals(v)) return "计费订单";
if ("REFUND".equals(v)) return "退款";
if ("ADJUST".equals(v)) return "手工调整";
if ("FREEZE".equals(v)) return "冻结";
if ("UNFREEZE".equals(v)) return "解冻";
return v;
}
}
@@ -0,0 +1,117 @@
package com.bms.customer.customermoney.vo;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import com.bms.common.annotation.Excel;
import com.bms.customer.customermoney.domain.CustomerCreditFlow;
import lombok.Data;
/**
* 客户额度流水导出 VO(列与页面额度流水 tab 对齐,枚举导出为中文文案)
*
* @author bms
* @date 2026-09-21
*/
@Data
public class CustomerCreditFlowExportVO implements Serializable
{
private static final long serialVersionUID = 1L;
@Excel(name = "流水号", width = 22)
private String flowNo;
@Excel(name = "时间", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@Excel(name = "业务单号", width = 22)
private String bizNo;
@Excel(name = "变动类型", width = 12)
private String changeTypeLabel;
@Excel(name = "变动维度", width = 18)
private String dimensionLabel;
@Excel(name = "调整方式", width = 10)
private String adjustModeLabel;
@Excel(name = "方向", width = 8)
private String directionLabel;
@Excel(name = "变动前", width = 12)
private BigDecimal beforeAmount;
@Excel(name = "变动额", width = 12)
private BigDecimal changeAmount;
@Excel(name = "变动后", width = 12)
private BigDecimal afterAmount;
@Excel(name = "已用额度快照", width = 14)
private BigDecimal usedCredit;
@Excel(name = "生效日期", width = 12, dateFormat = "yyyy-MM-dd")
private Date effectiveDate;
@Excel(name = "失效日期", width = 12)
private String expireDateText;
@Excel(name = "调整原因", width = 30)
private String reason;
@Excel(name = "操作人", width = 12)
private String createBy;
@Excel(name = "备注", width = 30)
private String remark;
public static CustomerCreditFlowExportVO from(CustomerCreditFlow f)
{
CustomerCreditFlowExportVO vo = new CustomerCreditFlowExportVO();
vo.setFlowNo(f.getFlowNo());
vo.setCreateTime(f.getCreateTime());
vo.setBizNo(f.getBizNo());
vo.setChangeTypeLabel(changeTypeLabel(f.getChangeType()));
boolean usageFlow = "OCCUPY".equals(f.getChangeType()) || "RELEASE".equals(f.getChangeType());
vo.setDimensionLabel(usageFlow ? "授信占用(已用额度)" : "授信额度");
vo.setAdjustModeLabel(adjustModeLabel(f.getAdjustMode()));
Integer dir = f.getDirection();
vo.setDirectionLabel(dir == null ? "-" : (dir == 1 ? "增加" : "减少"));
vo.setBeforeAmount(f.getBeforeAmount());
// 减少导出为负数(配合“方向”列阅读,同时保持 Excel 数值类型)
BigDecimal change = f.getChangeAmount();
if (change != null && dir != null && dir == -1)
{
change = change.negate();
}
vo.setChangeAmount(change);
vo.setAfterAmount(f.getAfterAmount());
// 占用/释放流水的已用额度快照在页面上不展示
vo.setUsedCredit(usageFlow ? null : f.getUsedCredit());
vo.setEffectiveDate(f.getEffectiveDate());
vo.setExpireDateText(f.getExpireDate() == null ? "长期" : new java.text.SimpleDateFormat("yyyy-MM-dd").format(f.getExpireDate()));
vo.setReason(f.getReason());
vo.setCreateBy(f.getCreateBy());
vo.setRemark(f.getRemark());
return vo;
}
private static String changeTypeLabel(String v)
{
if ("INIT".equals(v)) return "初始化";
if ("ADJUST".equals(v)) return "手工调整";
if ("EXPIRE".equals(v)) return "到期失效";
if ("OCCUPY".equals(v)) return "授信占用";
if ("RELEASE".equals(v)) return "授信释放";
return v;
}
private static String adjustModeLabel(String v)
{
if ("SET".equals(v)) return "设置为";
if ("ADD".equals(v)) return "增加";
if ("SUB".equals(v)) return "减少";
return v;
}
}
@@ -0,0 +1,116 @@
package com.bms.customer.customermoney.vo;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import com.bms.common.annotation.Excel;
import com.bms.customer.customermoney.domain.CustomerReceipt;
import lombok.Data;
/**
* 客户收款记录导出 VO(列与页面收款记录 tab 对齐,枚举导出为中文文案)
*
* @author bms
* @date 2026-09-21
*/
@Data
public class CustomerReceiptExportVO implements Serializable
{
private static final long serialVersionUID = 1L;
@Excel(name = "收款单号", width = 22)
private String receiptNo;
@Excel(name = "收款日期", width = 20, dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date receivedAt;
@Excel(name = "收款类型", width = 12)
private String receiptTypeLabel;
@Excel(name = "币种", width = 8)
private String currency;
@Excel(name = "原币金额", width = 14)
private BigDecimal amount;
@Excel(name = "汇率", width = 12)
private String exchangeRate;
@Excel(name = "汇率来源", width = 10)
private String rateSourceLabel;
@Excel(name = "本位币金额(CNY)", width = 16)
private BigDecimal baseAmount;
@Excel(name = "收款方式", width = 12)
private String payMethodLabel;
@Excel(name = "客户银行账号", width = 24)
private String bankAccountNo;
@Excel(name = "银行/平台流水号", width = 22)
private String bankTransactionNo;
@Excel(name = "状态", width = 10)
private String statusLabel;
@Excel(name = "登记人", width = 12)
private String createBy;
@Excel(name = "备注", width = 30)
private String remark;
public static CustomerReceiptExportVO from(CustomerReceipt r)
{
CustomerReceiptExportVO vo = new CustomerReceiptExportVO();
vo.setReceiptNo(r.getReceiptNo());
vo.setReceivedAt(r.getReceivedAt());
vo.setReceiptTypeLabel(receiptTypeLabel(r.getReceiptType()));
vo.setCurrency(r.getCurrency());
vo.setAmount(r.getAmount());
vo.setExchangeRate(r.getExchangeRate() == null ? null : r.getExchangeRate().stripTrailingZeros().toPlainString());
vo.setRateSourceLabel(rateSourceLabel(r.getRateSource()));
vo.setBaseAmount(r.getBaseAmount());
vo.setPayMethodLabel(payMethodLabel(r.getPayMethod()));
vo.setBankAccountNo(r.getBankAccountNo());
vo.setBankTransactionNo(r.getBankTransactionNo());
vo.setStatusLabel(statusLabel(r.getStatus()));
vo.setCreateBy(r.getCreateBy());
vo.setRemark(r.getRemark());
return vo;
}
private static String receiptTypeLabel(String v)
{
if ("PREPAY".equals(v)) return "预充值";
if ("REPAY".equals(v)) return "还款核销";
if ("MIXED".equals(v)) return "混合";
return v;
}
private static String statusLabel(String v)
{
if ("PENDING".equals(v)) return "待确认";
if ("CONFIRMED".equals(v)) return "已确认";
if ("CANCELLED".equals(v)) return "已取消";
return v;
}
private static String payMethodLabel(String v)
{
if ("BANK_TRANSFER".equals(v)) return "银行转账";
if ("ALIPAY".equals(v)) return "支付宝";
if ("WECHAT".equals(v)) return "微信";
if ("CASH".equals(v)) return "现金";
if ("OTHER".equals(v)) return "其他";
return v;
}
private static String rateSourceLabel(String v)
{
if ("RATE_TABLE".equals(v)) return "汇率表";
if ("MANUAL".equals(v)) return "手工修改";
if ("SYSTEM".equals(v)) return "系统默认";
return v;
}
}
@@ -0,0 +1,38 @@
package com.bms.customer.customermoney.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
* 角色额度池视图(角色管理/审批选池用)
*
* @author bms
* @date 2026-09-20
*/
@Data
public class RoleCreditQuotaVo
{
/** 角色ID */
private Long roleId;
/** 角色名称 */
private String roleName;
/** 权限字符 */
private String roleKey;
/** 角色状态(0正常 1停用) */
private String status;
/** 可分配额度总额;未配置记录时为0 */
private BigDecimal totalQuota;
/** 已分配(占用中台账余额合计) */
private BigDecimal usedQuota;
/** 剩余可分配 */
private BigDecimal availableQuota;
/** 备注 */
private String remark;
}