From 41ddcc525ab928cc06ab86ac1e4e8ca1024953d8 Mon Sep 17 00:00:00 2001
From: luojiaquan <1583622118@qq.com>
Date: Thu, 17 Sep 2026 17:27:35 +0800
Subject: [PATCH] =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E6=B5=81=E6=B0=B4=E6=98=BE?=
=?UTF-8?q?=E7=A4=BA=E9=97=AE=E9=A2=98=E4=BF=AE=E6=94=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
bms-back/ruoyi-admin/pom.xml | 7 +
.../CustomerMoneyController.java | 23 ++
.../CustomerAccountOccupyConcurrencyTest.java | 271 ++++++++++++++++++
.../api/ICustomerAccountApi.java | 12 +
.../api/impl/CustomerAccountApiImpl.java | 113 +++++++-
.../vo/CustomerAccountStatusVO.java | 97 +++++++
.../customerinfo/customermoney/index.vue | 27 +-
7 files changed, 531 insertions(+), 19 deletions(-)
create mode 100644 bms-back/ruoyi-admin/src/test/java/com/ruoyi/customer/customermoney/api/CustomerAccountOccupyConcurrencyTest.java
create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/vo/CustomerAccountStatusVO.java
diff --git a/bms-back/ruoyi-admin/pom.xml b/bms-back/ruoyi-admin/pom.xml
index 4d385c91..4643a4d7 100644
--- a/bms-back/ruoyi-admin/pom.xml
+++ b/bms-back/ruoyi-admin/pom.xml
@@ -48,6 +48,13 @@
ruoyi-quartz
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
com.ruoyi
diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java
index e8028dbd..2eea9114 100644
--- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java
+++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java
@@ -10,17 +10,20 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.domain.CustomerCreditApply;
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
import com.ruoyi.customer.customermoney.domain.CustomerReceipt;
import com.ruoyi.customer.customermoney.service.ICustomerMoneyService;
+import com.ruoyi.customer.customermoney.vo.CustomerAccountStatusVO;
/**
* 客户余额/收款/流水 Controller
@@ -35,6 +38,26 @@ public class CustomerMoneyController extends BaseController
@Autowired
private ICustomerMoneyService customerMoneyService;
+ @Autowired
+ private ICustomerAccountApi customerAccountApi;
+
+ /**
+ * 查询客户账号状态(收货扣件准入控制用)。支持 customerId 或 customerCode 二选一。
+ * 只读轻量查询,不锁账户也不物化临时额度失效。
+ */
+ @PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:list')")
+ @GetMapping("/account/status")
+ public AjaxResult accountStatus(@RequestParam(value = "customerId", required = false) Long customerId,
+ @RequestParam(value = "customerCode", required = false) String customerCode)
+ {
+ CustomerAccountStatusVO vo = customerAccountApi.getAccountStatus(customerId, customerCode);
+ if (vo == null)
+ {
+ return error("客户不存在");
+ }
+ return success(vo);
+ }
+
/**
* 查询客户各币种账户余额
*/
diff --git a/bms-back/ruoyi-admin/src/test/java/com/ruoyi/customer/customermoney/api/CustomerAccountOccupyConcurrencyTest.java b/bms-back/ruoyi-admin/src/test/java/com/ruoyi/customer/customermoney/api/CustomerAccountOccupyConcurrencyTest.java
new file mode 100644
index 00000000..4b4747ea
--- /dev/null
+++ b/bms-back/ruoyi-admin/src/test/java/com/ruoyi/customer/customermoney/api/CustomerAccountOccupyConcurrencyTest.java
@@ -0,0 +1,271 @@
+package com.ruoyi.customer.customermoney.api;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import com.ruoyi.common.core.domain.entity.SysUser;
+import com.ruoyi.common.core.domain.model.LoginUser;
+import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 客户授信额度「并发占用超额不透支」集成测试。
+ *
+ * 通过限制总额度后让 N 个线程同时占用额度,验证:
+ *
+ * - 已用额度 never 超过「总额度 + 临时额度」(不透支,靠 {@code select ... for update} 行锁串行);
+ *
- 只有最先抢到锁且额度足够的占用能成功,其余抛「可用额度不足」(靠行锁内重读最新已用额度,非乐观锁兜底的丢失更新);
+ *
- 额度充足场景并发占用不丢失任何一笔更新(累计正确)。
+ *
+ *
+ * 注意:这是针对真实开发库的集成测试,{@code setUp} 会把指定测试客户的 CNY 账户重置为受控额度,
+ * {@code tearDown} 恢复原值并物理删除测试产生的额度流水。请将 {@code CUSTOMER_ID} 换成专用测试客户ID。
+ *
+ * @author ruoyi
+ * @date 2026-09-17
+ */
+@SpringBootTest
+public class CustomerAccountOccupyConcurrencyTest
+{
+ /** 请替换为专用测试客户ID,切勿使用正在营业的真实客户 */
+ private static final Long CUSTOMER_ID = 1L;
+
+ private static final String CURRENCY = "CNY";
+
+ /** 并发线程数 */
+ private static final int THREADS = 10;
+
+ /** 单线程占用金额 */
+ private static final BigDecimal AMOUNT = new BigDecimal("100");
+
+ /** 测试捏造的总额度(使 10*100 超限,最多只能成功 1 笔) */
+ private static final BigDecimal TEST_TOTAL = new BigDecimal("150");
+
+ @Autowired
+ private ICustomerAccountApi accountApi;
+
+ @Autowired
+ private JdbcTemplate jdbc;
+
+ /** 每个 worker 线程复用的登录态(SecurityContextHolder 为线程本地,各线程独立 set) */
+ private Authentication authentication;
+
+ /** 本次测试由 occupyCredit 返回并落库的额度流水ID,用于 tearDown 清理 */
+ private final List createdFlowIds = Collections.synchronizedList(new ArrayList<>());
+
+ /** 客户账户被改写前的原值,tearDown 恢复用 */
+ private BigDecimal origTotal;
+ private BigDecimal origUsed;
+ private BigDecimal origExtra;
+
+ @BeforeEach
+ void setUp()
+ {
+ // 1) 若账户不存在则先创建(总额度=TEST_TOTAL,余额/已用/临时均为0),保证后续 update 有行可改
+ jdbc.update("INSERT INTO customer_account "
+ + "(customer_id, currency, balance, frozen_balance, total_credit, extra_credit, used_credit, tax, version, del_flag, create_by, create_time) "
+ + "SELECT ?, ?, 0, 0, ?, 0, 0, 0, 0, '0', '并发测试', now() "
+ + "FROM DUAL WHERE NOT EXISTS "
+ + "(SELECT 1 FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0')",
+ CUSTOMER_ID, CURRENCY, TEST_TOTAL, CUSTOMER_ID, CURRENCY);
+
+ // 2) 快照账户原值(用于恢复)
+ origTotal = queryAmount("SELECT total_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
+ origUsed = queryAmount("SELECT used_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
+ origExtra = queryAmount("SELECT extra_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
+
+ // 3) 重置为受控状态:总额度=TEST_TOTAL,已用=0,临时=0,并清掉历史额度流水(只清本客户,避免影响统计断言)
+ jdbc.update("UPDATE customer_account SET total_credit = ?, used_credit = 0, extra_credit = 0, "
+ + "extra_effective_date = NULL, extra_expire_date = NULL, version = version + 1 "
+ + "WHERE customer_id = ? AND currency = ? AND del_flag = '0'",
+ TEST_TOTAL, CUSTOMER_ID, CURRENCY);
+ jdbc.update("DELETE FROM customer_credit_flow WHERE customer_id = ? AND create_by = '并发测试'",
+ CUSTOMER_ID);
+
+ // 4) 构造登录态(occupyCredit 内部调用 SecurityUtils.getUsername()/getUserId())
+ SysUser user = new SysUser();
+ user.setUserName("并发测试用户");
+ LoginUser loginUser = new LoginUser(1L, null, user, null);
+ authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
+ }
+
+ @AfterEach
+ void tearDown()
+ {
+ // 1) 物理删除测试产生的额度流水
+ for (Long id : createdFlowIds)
+ {
+ jdbc.update("DELETE FROM customer_credit_flow WHERE id = ?", id);
+ }
+ // 2) 恢复账户原值
+ jdbc.update("UPDATE customer_account SET total_credit = ?, used_credit = ?, extra_credit = ? "
+ + "WHERE customer_id = ? AND currency = ? AND del_flag = '0'",
+ origTotal, origUsed, origExtra, CUSTOMER_ID, CURRENCY);
+ // 3) 清理线程本地上下文,避免污染其他用例
+ SecurityContextHolder.clearContext();
+ }
+
+ /**
+ * 核心场景:总额度150,10线程各占100。行锁串行后最多成功1笔(100<=150),
+ * 其余9笔在重读最新已用额度后判定可用不足拒绝,已用额度恒不得透支。
+ */
+ @Test
+ public void 并发占用超额_不透支且仅允许可容纳的成功()
+ throws InterruptedException
+ {
+ RunResult result = runConcurrentOccupy(THREADS, AMOUNT);
+
+ // 不透支核验:已用额度 <= 总额度 + 临时额度(透支出此被判定失败)
+ BigDecimal used = queryUsedCredit();
+ BigDecimal limit = TEST_TOTAL.add(queryExtraCredit());
+ assertTrue(used.compareTo(limit) <= 0,
+ "发生透支!used=" + used + " > 可受理上限=" + limit);
+
+ // 每笔100,总额度150 → 最多1笔成功;行锁保证首笔成功,故必须恰好1笔
+ assertEquals(1, result.successCount,
+ "并发占用结果数异常,成功=" + result.successCount + " 失败=" + result.failureCount);
+ assertEquals(new BigDecimal("100").setScale(2, RoundingMode.HALF_UP), used,
+ "已用额度应为100(只有一笔成功)");
+ assertTrue(result.hasInsufficientMessage(),
+ "应至少有一个并发请求因『可用额度不足』被拒绝,实际错误=" + result.errorMessages);
+ }
+
+ /**
+ * 对照场景:总额度充足(5000),5线程各占100 → 全部成功,已用=500,且累计无丢失。
+ */
+ @Test
+ public void 并发占用_额度充足时所有更新全部生效()
+ throws InterruptedException
+ {
+ jdbc.update("UPDATE customer_account SET total_credit = 5000, used_credit = 0 "
+ + "WHERE customer_id = ? AND currency = ? AND del_flag = '0'", CUSTOMER_ID, CURRENCY);
+
+ RunResult result = runConcurrentOccupy(5, AMOUNT);
+
+ assertEquals(5, result.successCount, "额度充足场景应5笔全部成功");
+ BigDecimal used = queryUsedCredit();
+ BigDecimal expected = new BigDecimal("500").setScale(2, RoundingMode.HALF_UP);
+ assertEquals(expected, used, "已用额度应精确等于5*100=500,不允许任何丢失更新");
+ assertTrue(used.compareTo(queryTotalCredit()) <= 0, "不应透支");
+ }
+
+ /**
+ * 并发执行占用:CountDownLatch 栅栏保证所有线程同时开跑,最大化争用同一把行锁。
+ */
+ private RunResult runConcurrentOccupy(int n, BigDecimal amount) throws InterruptedException
+ {
+ CountDownLatch ready = new CountDownLatch(n);
+ CountDownLatch go = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(n);
+ ExecutorService pool = Executors.newFixedThreadPool(n);
+ AtomicInteger success = new AtomicInteger();
+ List errorMessages = Collections.synchronizedList(new ArrayList<>());
+
+ for (int i = 0; i < n; i++)
+ {
+ pool.execute(() ->
+ {
+ try
+ {
+ // SecurityContextHolder 线程本地:每个 worker 各自注入登录态
+ SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ ready.countDown();
+ go.await();
+ CreditUsageCommand cmd = new CreditUsageCommand();
+ cmd.setCustomerId(CUSTOMER_ID);
+ cmd.setCurrency(CURRENCY);
+ cmd.setBizType(CustomerFundBizType.ORDER);
+ cmd.setBizNo("并发测试-" + Thread.currentThread().getName());
+ cmd.setAmount(amount);
+ CustomerCreditFlow flow = accountApi.occupyCredit(cmd);
+ if (flow != null)
+ {
+ success.incrementAndGet();
+ createdFlowIds.add(flow.getId());
+ }
+ }
+ catch (Exception e)
+ {
+ errorMessages.add(String.valueOf(e.getMessage()));
+ }
+ finally
+ {
+ // 标记该线程真正结束,绝不让僵尸线程泄漏到下一个用例
+ done.countDown();
+ }
+ });
+ }
+
+ ready.await();
+ go.countDown();
+ // 必等所有 n 个任务真正执行完毕才返回,超时直接判失败(而不是泄漏继续跑)
+ boolean finished = done.await(300, TimeUnit.SECONDS);
+ pool.shutdownNow();
+ pool.awaitTermination(30, TimeUnit.SECONDS);
+ assertTrue(finished, "并发任务未在限定时间内全部结束,可能已泄漏到下一用例");
+
+ return new RunResult(success.get(), errorMessages);
+ }
+
+ private BigDecimal queryUsedCredit()
+ {
+ return queryAmount("SELECT used_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
+ }
+
+ private BigDecimal queryTotalCredit()
+ {
+ return queryAmount("SELECT total_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
+ }
+
+ private BigDecimal queryExtraCredit()
+ {
+ return queryAmount("SELECT extra_credit FROM customer_account WHERE customer_id = ? AND currency = ? AND del_flag = '0'");
+ }
+
+ private BigDecimal queryAmount(String sql)
+ {
+ BigDecimal value = jdbc.queryForObject(sql, BigDecimal.class, CUSTOMER_ID, CURRENCY);
+ return value == null ? BigDecimal.ZERO : value;
+ }
+
+ /** 一次并发运行的汇总结果 */
+ private static class RunResult
+ {
+ final int successCount;
+ final int failureCount;
+ final List errorMessages;
+
+ RunResult(int success, List errors)
+ {
+ this.successCount = success;
+ this.failureCount = errors.size();
+ this.errorMessages = errors;
+ }
+
+ boolean hasInsufficientMessage()
+ {
+ return errorMessages.stream().anyMatch(m -> m != null && m.contains("可用额度不足"));
+ }
+ }
+}
\ No newline at end of file
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
index bd1812fa..36db4a25 100644
--- 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
@@ -3,6 +3,7 @@ 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;
+import com.ruoyi.customer.customermoney.vo.CustomerAccountStatusVO;
/**
* 客户资金公共服务:客户余额扣减/回补、授信额度占用/释放的统一入口。
@@ -90,4 +91,15 @@ public interface ICustomerAccountApi
* @return 已落库的余额流水(含ID、账户ID)
*/
CustomerBalanceFlow refundBalance(BalanceChangeCommand command);
+
+ /**
+ * 查询客户账号状态(收货扣件准入控制用)。支持 customerId / customerCode 二选一,只读轻量查询,
+ * 不锁账户也不物化临时额度失效——临时额度已过期时按"视为0"计算可用额度,但 DB 保持原状,
+ * 由定时任务或后续真实资金入口落库。
+ *
+ * 欠费定义:可用额度 < 0(已用额度超过授信总额+有效临时额度)。
+ *
+ * @return 账号状态VO;客户不存在时返回 null
+ */
+ CustomerAccountStatusVO getAccountStatus(Long customerId, String customerCode);
}
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
index d8730a26..ad15e3e1 100644
--- 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
@@ -28,6 +28,7 @@ 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.vo.CustomerAccountStatusVO;
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
import com.ruoyi.customer.customermoney.mapper.CustomerMoneyMapper;
import com.ruoyi.system.domain.BmsExchangeRate;
@@ -574,7 +575,7 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
}
/**
- * 解析客户标识:customerId 优先;未传时按 customerCode 精确查询有效客户档案。
+ * 解析额度命令的客户标识:customerId 优先;未传时按 customerCode 精确查询有效客户档案。
* 编码不存在或匹配多条时抛异常,避免资金变动落到错误客户。
*/
private Long resolveCustomerId(CreditUsageCommand command)
@@ -583,24 +584,108 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi
{
return command.getCustomerId();
}
- String customerCode = command.getCustomerCode().trim();
- BmsCustomer query = new BmsCustomer();
- query.setCustomerCode(customerCode);
- List customers = bmsCustomerMapper.selectBmsCustomerList(query);
- if (customers == null || customers.isEmpty())
- {
- throw new ServiceException("客户编码不存在:" + customerCode);
- }
- if (customers.size() > 1)
- {
- throw new ServiceException("客户编码匹配到多个客户:" + customerCode);
- }
- Long customerId = customers.get(0).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 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 totalCredit = account != null ? nz(account.getTotalCredit()) : BigDecimal.ZERO;
+ BigDecimal rawExtra = account != null ? nz(account.getExtraCredit()) : BigDecimal.ZERO;
+ BigDecimal usedCredit = account != null ? nz(account.getUsedCredit()) : BigDecimal.ZERO;
+
+ // 临时额度已过期则视为0(仅内存计算,不物化DB;由定时任务/真实资金入口落库)
+ boolean expiredPending = isTempCreditExpired(account);
+ BigDecimal effectiveExtra = expiredPending ? BigDecimal.ZERO : rawExtra;
+
+ vo.setBalance(account != null ? nz(account.getBalance()) : BigDecimal.ZERO);
+ vo.setFrozenBalance(account != null ? nz(account.getFrozenBalance()) : BigDecimal.ZERO);
+ vo.setTotalCredit(totalCredit);
+ vo.setExtraCredit(rawExtra);
+ vo.setEffectiveExtraCredit(effectiveExtra);
+ vo.setUsedCredit(usedCredit);
+ BigDecimal available = totalCredit.add(effectiveExtra).subtract(usedCredit);
+ vo.setAvailableCredit(available);
+ boolean overdue = available.compareTo(BigDecimal.ZERO) < 0;
+ vo.setOverdue(overdue);
+ vo.setExpiredTempCreditPending(expiredPending);
+
+ 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)
diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/vo/CustomerAccountStatusVO.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/vo/CustomerAccountStatusVO.java
new file mode 100644
index 00000000..77ea62bf
--- /dev/null
+++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/vo/CustomerAccountStatusVO.java
@@ -0,0 +1,97 @@
+package com.ruoyi.customer.customermoney.vo;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 客户账号状态查询VO(收货扣件准入控制用)。
+ *
+ * 只读轻量,不锁账户也不物化临时额度失效:临时额度已过期时按"视为0"计算可用额度,
+ * 但 DB 保持原状——由定时任务或后续真实资金入口落库。
+ *
+ * 欠费定义:availableCredit < 0(已用额度超过授信总额+有效临时额度)。
+ *
+ * @author ruoyi
+ * @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停用)—— 来自 bms_customer.is_enable */
+ private String isEnable;
+ /** 启用状态文案(正常 / 停用) */
+ private String isEnableLabel;
+
+ /** CNY账户余额 */
+ private BigDecimal balance;
+ /** CNY账户冻结余额 */
+ private BigDecimal frozenBalance;
+ /** 授信总额度(CNY) */
+ private BigDecimal totalCredit;
+ /** 临时额度原值(CNY,DB 中 extra_credit 字段) */
+ private BigDecimal extraCredit;
+ /** 临时额度实际有效值(过期时为0,接口计算得出,不物化DB) */
+ private BigDecimal effectiveExtraCredit;
+ /** 已用额度(CNY) */
+ private BigDecimal usedCredit;
+ /** 可用额度(总额度 + 有效临时额度 - 已用额度;可能为负) */
+ 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 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 getUsedCredit() { return usedCredit; }
+ public void setUsedCredit(BigDecimal usedCredit) { this.usedCredit = usedCredit; }
+ 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; }
+}
diff --git a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue
index d4b2e825..3c9e88e5 100644
--- a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue
+++ b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue
@@ -297,6 +297,12 @@
+
+
+ 授信占用(已用额度)
+ 授信额度
+
+
{{ formatOption(adjustModeOptions, scope.row.adjustMode) }}
@@ -307,21 +313,24 @@
-
-
+
{{ formatMoney(scope.row.beforeAmount) }}
-
+
{{ scope.row.direction === 1 ? '+' : '-' }}{{ formatMoney(scope.row.changeAmount) }}
-
+
{{ formatMoney(scope.row.afterAmount) }}
-
- {{ formatMoney(scope.row.usedCredit) }}
+
+
+ -
+ {{ formatMoney(scope.row.usedCredit) }}
+
{{ scope.row.effectiveDate || '-' }}
@@ -987,6 +996,14 @@ const adjustModeOptions = [
function creditChangeTag(v) {
return { INIT: '', ADJUST: 'primary', EXPIRE: 'danger', OCCUPY: 'warning', RELEASE: 'success' }[v] || 'info'
}
+/**
+ * 是否为授信占用/释放流水。
+ * 占用/释放流水的 变动前/变动额/变动后 记的是【已用额度】维度;
+ * INIT/ADJUST/EXPIRE 流水这三列记的是【授信额度】(信用/临时额度)维度。
+ */
+function isCreditUsageFlow(changeType) {
+ return changeType === 'OCCUPY' || changeType === 'RELEASE'
+}
function formatMoney(val) {
const num = Number(val)
return isNaN(num) ? '0.00' : num.toFixed(2)