diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/BalanceChangeCommand.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/BalanceChangeCommand.java
new file mode 100644
index 00000000..f0026866
--- /dev/null
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/BalanceChangeCommand.java
@@ -0,0 +1,63 @@
+package com.ruoyi.customer.customermoney.api;
+
+import java.math.BigDecimal;
+import lombok.Data;
+
+/**
+ * 客户账户余额变动命令(扣减/回补余额,写余额流水;可选联动已用额度)。
+ *
+ *
典型场景:
+ *
+ * - 余额核销账单/订单扣款 -> {@link ICustomerAccountApi#deductBalance}(余额减少,默认同步释放已用额度)
+ * - 收款审核入账/反核销回补/退款 -> {@link ICustomerAccountApi#refundBalance}(余额增加)
+ *
+ *
+ * @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;
+}
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/CreditUsageCommand.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/CreditUsageCommand.java
new file mode 100644
index 00000000..3a22a597
--- /dev/null
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/CreditUsageCommand.java
@@ -0,0 +1,49 @@
+package com.ruoyi.customer.customermoney.api;
+
+import java.math.BigDecimal;
+import lombok.Data;
+
+/**
+ * 授信额度占用/释放命令(变动客户账户已用额度 used_credit,并写额度流水)。
+ *
+ * 典型场景:
+ *
+ * - 计费订单开单/应收账单确认 -> {@link ICustomerAccountApi#occupyCredit}(已用额度增加)
+ * - 账单作废/计费变动核减 -> {@link ICustomerAccountApi#releaseCredit}(已用额度减少)
+ *
+ *
+ * @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;
+}
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/CustomerFundBizType.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/CustomerFundBizType.java
new file mode 100644
index 00000000..d1d23de3
--- /dev/null
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/CustomerFundBizType.java
@@ -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()
+ {
+ }
+}
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/ICustomerAccountApi.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/ICustomerAccountApi.java
new file mode 100644
index 00000000..23ba3bdf
--- /dev/null
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/ICustomerAccountApi.java
@@ -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;
+
+/**
+ * 客户资金公共服务:客户余额扣减/回补、授信额度占用/释放的统一入口。
+ *
+ * 客户余额、授信额度及其流水(客户模块资产)的所有变动都必须经过本接口,
+ * 其他模块(应收账单、计费订单/计费变动等)不得直接更新 customer_account /
+ * 直接插入流水表。每个方法在同一事务内完成:行锁账户 -> 乐观锁变动 -> 写流水,
+ * 调用方自身的事务会与本操作共享事务(默认 REQUIRED),任一失败整体回滚。
+ *
+ *
+ * - 单据形成应收/计费开单:{@link #occupyCredit} 占用授信(used_credit 增加)
+ * - 单据作废/计费核减:{@link #releaseCredit} 释放授信(used_credit 减少)
+ * - 用账户余额支付/核销:{@link #deductBalance} 扣余额(可同步释放已用额度)
+ * - 收款入账/退款/反核销回补:{@link #refundBalance} 加余额(可同步重新占用)
+ *
+ *
+ * @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);
+}
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/impl/CustomerAccountApiImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/impl/CustomerAccountApiImpl.java
new file mode 100644
index 00000000..bbc133d5
--- /dev/null
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/api/impl/CustomerAccountApiImpl.java
@@ -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;
+ }
+}
diff --git a/bms-back/sql/customer+supplier/customer_credit_flow_biz_columns.sql b/bms-back/sql/customer+supplier/customer_credit_flow_biz_columns.sql
new file mode 100644
index 00000000..213ed539
--- /dev/null
+++ b/bms-back/sql/customer+supplier/customer_credit_flow_biz_columns.sql
@@ -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`;