提交客户/供应商管理 下拉组件
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
package com.ruoyi.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 ruoyi
|
||||
* @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;
|
||||
|
||||
/** 扣减余额时是否同步释放等额已用额度(账单核销场景为true),默认true */
|
||||
private Boolean releaseUsedCredit = Boolean.TRUE;
|
||||
|
||||
/** 回补余额时是否同步重新占用等额已用额度(反核销场景为true,收款入账为false),默认false */
|
||||
private Boolean reoccupyUsedCredit = Boolean.FALSE;
|
||||
|
||||
/** 扣减时是否校验余额充足(不足抛异常),默认true */
|
||||
private Boolean checkBalanceEnough = Boolean.TRUE;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.ruoyi.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>
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Data
|
||||
public class CreditUsageCommand
|
||||
{
|
||||
/** 客户ID(必填) */
|
||||
private Long customerId;
|
||||
|
||||
/** 币种(为空按本位币 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;
|
||||
|
||||
/**
|
||||
* 占用时是否校验可用额度(授信总额-已用 >= 本次占用额)。
|
||||
* true=余额/额度不足直接抛异常(计费开单等新业务场景默认值);
|
||||
* false=允许超出总额度占用(兼容历史账单确认口径)。
|
||||
* 释放场景该参数无意义。
|
||||
*/
|
||||
private Boolean checkAvailable = Boolean.TRUE;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.ruoyi.customer.customermoney.api;
|
||||
|
||||
/**
|
||||
* 客户资金变动业务来源类型(写入余额流水 biz_type / 额度流水 biz_type)。
|
||||
* 各业务模块调用 {@link ICustomerAccountApi} 时传本类常量,便于按来源单据反查流水。
|
||||
*
|
||||
* @author ruoyi
|
||||
* @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()
|
||||
{
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.ruoyi.customer.customermoney.api;
|
||||
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
|
||||
|
||||
/**
|
||||
* 客户资金公共服务:客户余额扣减/回补、授信额度占用/释放的统一入口。
|
||||
*
|
||||
* <p>客户余额、授信额度及其流水(客户模块资产)的所有变动都必须经过本接口,
|
||||
* 其他模块(应收账单、计费订单/计费变动等)不得直接更新 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 ruoyi
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
public interface ICustomerAccountApi
|
||||
{
|
||||
/**
|
||||
* 普通查询客户某币种账户(不加锁,不存在返回null),供业务模块判断余额/可用额度。
|
||||
*/
|
||||
CustomerAccount getAccount(Long customerId, String currency);
|
||||
|
||||
/**
|
||||
* 事务内行锁获取客户CNY账户,不存在则按客户档案额度自动初始化(不处理临时额度过期)。
|
||||
* 调用方拿到后如需最新临时额度,可再调 {@link #expireTempCreditIfNeeded}。
|
||||
*/
|
||||
CustomerAccount lockCnyAccount(Long customerId);
|
||||
|
||||
/**
|
||||
* 临时额度到期自动失效(额度清零 + 回写客户档案 + EXPIRE 额度流水)。
|
||||
* 入参必须是同一事务内行锁查出的账户;未过期时不做任何操作。
|
||||
*/
|
||||
void expireTempCreditIfNeeded(CustomerAccount account);
|
||||
|
||||
/**
|
||||
* 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY,档案有值按档案),
|
||||
* 写 INIT 额度流水;CNY账户已存在时不重复创建。
|
||||
*/
|
||||
int initDefaultCnyAccount(Long customerId);
|
||||
|
||||
/**
|
||||
* 占用授信额度:used_credit += amount,写 OCCUPY 额度流水(关联来源单据)。
|
||||
* checkAvailable=true 时可用额度不足抛 ServiceException;CNY账户不存在自动初始化,
|
||||
* 外币账户不存在时静默跳过(返回null,兼容历史口径)。
|
||||
*
|
||||
* @return 已落库的额度流水(含ID);账户不存在跳过场景返回null
|
||||
*/
|
||||
CustomerCreditFlow occupyCredit(CreditUsageCommand command);
|
||||
|
||||
/**
|
||||
* 释放授信额度:used_credit -= amount(不小于0),写 RELEASE 额度流水。
|
||||
* 账户不存在时静默跳过(返回null)。
|
||||
*
|
||||
* @return 已落库的额度流水(含ID);账户不存在跳过场景返回null
|
||||
*/
|
||||
CustomerCreditFlow releaseCredit(CreditUsageCommand command);
|
||||
|
||||
/**
|
||||
* 扣减账户余额:balance -= amount,写 -1 余额流水;
|
||||
* releaseUsedCredit=true 时同步释放等额已用额度(余额清偿应收场景)。
|
||||
* 账户不存在或余额不足(checkBalanceEnough=true)抛 ServiceException。
|
||||
*
|
||||
* @return 已落库的余额流水(含ID、账户ID)
|
||||
*/
|
||||
CustomerBalanceFlow deductBalance(BalanceChangeCommand command);
|
||||
|
||||
/**
|
||||
* 回补/入账账户余额:balance += amount,写 +1 余额流水;
|
||||
* reoccupyUsedCredit=true 时同步加回等额已用额度(反核销场景),
|
||||
* 收款审核入账等场景保持false。
|
||||
*
|
||||
* @return 已落库的余额流水(含ID、账户ID)
|
||||
*/
|
||||
CustomerBalanceFlow refundBalance(BalanceChangeCommand command);
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
package com.ruoyi.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.concurrent.ThreadLocalRandom;
|
||||
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.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.customer.customerinfo.domain.BmsCustomer;
|
||||
import com.ruoyi.customer.customerinfo.mapper.BmsCustomerMapper;
|
||||
import com.ruoyi.customer.customermoney.api.BalanceChangeCommand;
|
||||
import com.ruoyi.customer.customermoney.api.CreditUsageCommand;
|
||||
import com.ruoyi.customer.customermoney.api.CustomerFundBizType;
|
||||
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
|
||||
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
|
||||
import com.ruoyi.customer.customermoney.mapper.CustomerMoneyMapper;
|
||||
import com.ruoyi.system.domain.BmsExchangeRate;
|
||||
|
||||
/**
|
||||
* 客户资金公共服务实现:客户账户余额/授信额度变动与流水落库的唯一收口。
|
||||
* 加锁顺序统一为「业务单据(调用方持锁)-> 客户账户(本服务行锁)」。
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Service
|
||||
public class CustomerAccountApiImpl implements ICustomerAccountApi
|
||||
{
|
||||
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
|
||||
|
||||
/** 本位币币种 */
|
||||
private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY;
|
||||
/** 新客户默认总额度(CNY) */
|
||||
private static final BigDecimal DEFAULT_CREDIT = new BigDecimal("1000");
|
||||
/** 金额保留位数 */
|
||||
private static final int MONEY_SCALE = 2;
|
||||
|
||||
@Autowired
|
||||
private CustomerMoneyMapper customerMoneyMapper;
|
||||
|
||||
@Autowired
|
||||
private BmsCustomerMapper bmsCustomerMapper;
|
||||
|
||||
@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 = DEFAULT_CREDIT;
|
||||
}
|
||||
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, "新客户默认初始化额度1000 CNY");
|
||||
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
|
||||
{
|
||||
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
|
||||
initTemp, null, null, username, "新客户初始化临时额度");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void expireTempCreditIfNeeded(CustomerAccount account)
|
||||
{
|
||||
if (account == null || !isTempCreditExpired(account))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String username = SecurityUtils.getUsername();
|
||||
BigDecimal before = nz(account.getExtraCredit());
|
||||
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()), BigDecimal.ZERO, username);
|
||||
|
||||
CustomerCreditFlow flow = new CustomerCreditFlow();
|
||||
flow.setFlowNo(generateNo("EDLS"));
|
||||
flow.setCustomerId(account.getCustomerId());
|
||||
flow.setAccountId(account.getId());
|
||||
flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_TEMP);
|
||||
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(account.getExtraEffectiveDate());
|
||||
flow.setExpireDate(account.getExtraExpireDate());
|
||||
flow.setReason("临时额度到期自动失效");
|
||||
flow.setOperatorId(SecurityUtils.getUserId());
|
||||
flow.setCreateBy(username);
|
||||
flow.setRemark("系统在访问账户时检测到临时额度已过失效日期,自动置零");
|
||||
flow.setDelFlag("0");
|
||||
customerMoneyMapper.insertCreditFlow(flow);
|
||||
}
|
||||
|
||||
/* ================= 授信额度占用 / 释放 ================= */
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CustomerCreditFlow occupyCredit(CreditUsageCommand command)
|
||||
{
|
||||
validateUsageCommand(command);
|
||||
String currency = resolveCurrency(command.getCurrency());
|
||||
String username = SecurityUtils.getUsername();
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
CustomerAccount account = lockAccountForUsage(command.getCustomerId(), currency, username);
|
||||
if (account == null)
|
||||
{
|
||||
// 外币账户尚未开立时不自动占用,与历史账单口径保持一致(CNY账户会自动初始化)
|
||||
return null;
|
||||
}
|
||||
BigDecimal amount = money(command.getAmount());
|
||||
BigDecimal beforeUsed = nz(account.getUsedCredit());
|
||||
BigDecimal totalAvailable = nz(account.getTotalCredit()).add(nz(account.getExtraCredit()))
|
||||
.subtract(beforeUsed);
|
||||
if (Boolean.TRUE.equals(command.getCheckAvailable()) && totalAvailable.compareTo(amount) < 0)
|
||||
{
|
||||
throw new ServiceException("客户可用额度不足,当前可用额度:" + totalAvailable.toPlainString()
|
||||
+ " " + currency + ",本次需占用:" + amount.toPlainString());
|
||||
}
|
||||
BigDecimal afterUsed = beforeUsed.add(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
account.setUsedCredit(afterUsed);
|
||||
account.setUpdateBy(username);
|
||||
if (customerMoneyMapper.updateAccountBalance(account) == 0)
|
||||
{
|
||||
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
|
||||
}
|
||||
return writeUsedCreditFlow(account, command, CustomerCreditFlow.CHANGE_OCCUPY, 1,
|
||||
beforeUsed, afterUsed, amount, username, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CustomerCreditFlow releaseCredit(CreditUsageCommand command)
|
||||
{
|
||||
validateUsageCommand(command);
|
||||
String currency = resolveCurrency(command.getCurrency());
|
||||
String username = SecurityUtils.getUsername();
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
CustomerAccount account = lockAccountForUsage(command.getCustomerId(), currency, username);
|
||||
if (account == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
BigDecimal amount = money(command.getAmount());
|
||||
BigDecimal beforeUsed = nz(account.getUsedCredit());
|
||||
BigDecimal afterUsed = beforeUsed.subtract(amount).max(BigDecimal.ZERO)
|
||||
.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
if (afterUsed.compareTo(beforeUsed) == 0)
|
||||
{
|
||||
// 无实际占用可释放,仍返回null不产生流水
|
||||
return null;
|
||||
}
|
||||
account.setUsedCredit(afterUsed);
|
||||
account.setUpdateBy(username);
|
||||
if (customerMoneyMapper.updateAccountBalance(account) == 0)
|
||||
{
|
||||
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
|
||||
}
|
||||
return writeUsedCreditFlow(account, command, CustomerCreditFlow.CHANGE_RELEASE, -1,
|
||||
beforeUsed, afterUsed, amount, username, userId);
|
||||
}
|
||||
|
||||
/* ================= 余额扣减 / 回补 ================= */
|
||||
|
||||
@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);
|
||||
if (Boolean.TRUE.equals(command.getReleaseUsedCredit()))
|
||||
{
|
||||
account.setUsedCredit(nz(account.getUsedCredit()).subtract(changeAmount).max(BigDecimal.ZERO)
|
||||
.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
|
||||
}
|
||||
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);
|
||||
if (Boolean.TRUE.equals(command.getReoccupyUsedCredit()))
|
||||
{
|
||||
// 反核销回补:账单恢复未清偿,授信占用同步加回(允许出现高于总额度的占用存量)
|
||||
account.setUsedCredit(nz(account.getUsedCredit()).add(changeAmount)
|
||||
.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/* ================= 内部实现 ================= */
|
||||
|
||||
/** 额度占用/释放写额度流水(before/after 为已用额度快照) */
|
||||
private CustomerCreditFlow writeUsedCreditFlow(CustomerAccount account, CreditUsageCommand command,
|
||||
String changeType, int direction, BigDecimal beforeUsed, BigDecimal afterUsed,
|
||||
BigDecimal amount, String username, Long userId)
|
||||
{
|
||||
CustomerCreditFlow flow = new CustomerCreditFlow();
|
||||
flow.setFlowNo(generateNo("EDLS"));
|
||||
flow.setCustomerId(account.getCustomerId());
|
||||
flow.setAccountId(account.getId());
|
||||
flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_CREDIT);
|
||||
flow.setChangeType(changeType);
|
||||
flow.setBizType(command.getBizType());
|
||||
flow.setBizId(command.getBizId());
|
||||
flow.setBizNo(command.getBizNo());
|
||||
flow.setDirection(direction);
|
||||
flow.setBeforeAmount(beforeUsed);
|
||||
flow.setChangeAmount(amount);
|
||||
flow.setAfterAmount(afterUsed);
|
||||
flow.setUsedCredit(afterUsed);
|
||||
flow.setReason(resolveUsageReason(command, changeType));
|
||||
flow.setOperatorId(userId);
|
||||
flow.setCreateBy(username);
|
||||
flow.setDelFlag("0");
|
||||
customerMoneyMapper.insertCreditFlow(flow);
|
||||
return flow;
|
||||
}
|
||||
|
||||
/** 余额变动写余额流水(原币金额留痕,本位币金额参与余额变动) */
|
||||
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);
|
||||
expireTempCreditIfNeeded(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);
|
||||
expireTempCreditIfNeeded(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)
|
||||
{
|
||||
BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new ServiceException("客户不存在");
|
||||
}
|
||||
BigDecimal initCredit = nz(customer.getCreditEd());
|
||||
BigDecimal initTemp = nz(customer.getTempEd());
|
||||
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;
|
||||
}
|
||||
|
||||
/** 创建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("账户初始化");
|
||||
flow.setOperatorId(SecurityUtils.getUserId());
|
||||
flow.setCreateBy(username);
|
||||
flow.setRemark(remark);
|
||||
flow.setDelFlag("0");
|
||||
customerMoneyMapper.insertCreditFlow(flow);
|
||||
}
|
||||
|
||||
private void validateUsageCommand(CreditUsageCommand 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 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 String resolveUsageReason(CreditUsageCommand command, String changeType)
|
||||
{
|
||||
if (StringUtils.hasText(command.getReason()))
|
||||
{
|
||||
return command.getReason().trim();
|
||||
}
|
||||
String action = CustomerCreditFlow.CHANGE_OCCUPY.equals(changeType) ? "占用授信额度" : "释放授信额度";
|
||||
String biz = bizTypeLabel(command.getBizType());
|
||||
String reason = biz + action;
|
||||
if (StringUtils.hasText(command.getBizNo()))
|
||||
{
|
||||
reason = reason + ":" + command.getBizNo();
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
private String bizTypeLabel(String bizType)
|
||||
{
|
||||
if (CustomerFundBizType.AR_BILL.equals(bizType))
|
||||
{
|
||||
return "应收账单";
|
||||
}
|
||||
if (CustomerFundBizType.ORDER.equals(bizType))
|
||||
{
|
||||
return "计费订单";
|
||||
}
|
||||
if (CustomerFundBizType.BILL_PAY.equals(bizType))
|
||||
{
|
||||
return "账单核销";
|
||||
}
|
||||
return "业务";
|
||||
}
|
||||
|
||||
/** 临时额度是否已过期:有失效日期且今天已晚于失效日(失效日当天仍有效) */
|
||||
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 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,10 @@
|
||||
-- =============================================================
|
||||
-- 额度流水增加业务来源关联列
|
||||
-- 授信额度的占用/释放由各业务模块(应收账单确认、计费订单开单/计费变动)
|
||||
-- 通过客户资金公共API ICustomerAccountApi 触发,流水需能反查来源单据
|
||||
-- 2026-09-17
|
||||
-- =============================================================
|
||||
ALTER TABLE `customer_credit_flow`
|
||||
ADD COLUMN `biz_type` VARCHAR(32) DEFAULT NULL COMMENT '业务来源类型:AR_BILL应收账单 ORDER计费订单(占用/释放流水)' AFTER `change_type`,
|
||||
ADD COLUMN `biz_id` BIGINT(20) DEFAULT NULL COMMENT '关联业务单据ID' AFTER `biz_type`,
|
||||
ADD COLUMN `biz_no` VARCHAR(64) DEFAULT NULL COMMENT '关联业务单据号' AFTER `biz_id`;
|
||||
Reference in New Issue
Block a user