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 个线程同时占用额度,验证: + *

    + *
  1. 已用额度 never 超过「总额度 + 临时额度」(不透支,靠 {@code select ... for update} 行锁串行); + *
  2. 只有最先抢到锁且额度足够的占用能成功,其余抛「可用额度不足」(靠行锁内重读最新已用额度,非乐观锁兜底的丢失更新); + *
  3. 额度充足场景并发占用不丢失任何一笔更新(累计正确)。 + *

+ * + *

注意:这是针对真实开发库的集成测试,{@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 @@ + + + @@ -307,21 +313,24 @@ - - + - + - + - - + + @@ -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)