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 2eea9114..9fee4e76 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 @@ -22,8 +22,10 @@ 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.ICreditQuotaService; import com.ruoyi.customer.customermoney.service.ICustomerMoneyService; import com.ruoyi.customer.customermoney.vo.CustomerAccountStatusVO; +import com.ruoyi.common.utils.SecurityUtils; /** * 客户余额/收款/流水 Controller @@ -41,6 +43,9 @@ public class CustomerMoneyController extends BaseController @Autowired private ICustomerAccountApi customerAccountApi; + @Autowired + private ICreditQuotaService creditQuotaService; + /** * 查询客户账号状态(收货扣件准入控制用)。支持 customerId 或 customerCode 二选一。 * 只读轻量查询,不锁账户也不物化临时额度失效。 @@ -197,7 +202,8 @@ public class CustomerMoneyController extends BaseController } /** - * 审批通过额度调整申请:通过后调整账户额度并生成额度流水 + * 审批通过额度调整申请:通过后调整账户额度并生成额度流水。 + * 非超管审批净增额度时 body 须带 approveRoleId(占用该角色额度池)。 */ @PreAuthorize("@ss.hasPermi('customer:credit:approve')") @Log(title = "客户额度调整审批", businessType = BusinessType.UPDATE) @@ -206,7 +212,18 @@ public class CustomerMoneyController extends BaseController @RequestBody(required = false) CustomerCreditApply body) { String remark = body != null ? body.getApproveRemark() : null; - return toAjax(customerMoneyService.approveCreditApply(applyId, remark)); + Long approveRoleId = body != null ? body.getApproveRoleId() : null; + return toAjax(customerMoneyService.approveCreditApply(applyId, remark, approveRoleId)); + } + + /** + * 当前登录用户审批时可选择的角色额度池(超管返回空列表,前端不展示选框)。 + */ + @PreAuthorize("@ss.hasPermi('customer:credit:approve')") + @GetMapping("/credit/quota/my-pools") + public AjaxResult myCreditPools() + { + return success(creditQuotaService.myPools(SecurityUtils.getUserId())); } /** diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/RoleCreditQuotaController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/RoleCreditQuotaController.java new file mode 100644 index 00000000..92657bdb --- /dev/null +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/RoleCreditQuotaController.java @@ -0,0 +1,66 @@ +package com.ruoyi.web.controller.system; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +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.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.domain.CustomerCreditGrant; +import com.ruoyi.customer.customermoney.service.ICreditQuotaService; +import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo; + +/** + * 角色可分配额度总额(角色额度池)管理 + * + * @author ruoyi + * @date 2026-09-20 + */ +@RestController +@RequestMapping("/system/role/creditQuota") +public class RoleCreditQuotaController extends BaseController +{ + @Autowired + private ICreditQuotaService creditQuotaService; + + /** + * 全部角色额度池列表(含已分配/剩余可分配,实时统计) + */ + @PreAuthorize("@ss.hasPermi('system:role:list')") + @GetMapping("/list") + public AjaxResult list() + { + return success(creditQuotaService.listRoleQuotas()); + } + + /** + * 设置角色可分配额度总额(不能小于该池当前已分配额) + */ + @PreAuthorize("@ss.hasPermi('system:role:edit')") + @Log(title = "角色额度池设置", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult set(@RequestBody RoleCreditQuotaVo body) + { + creditQuotaService.setRoleQuota(body.getRoleId(), body.getTotalQuota(), body.getRemark()); + return success(); + } + + /** + * 额度池占用台账查询(可按角色/客户/类型/状态筛选) + */ + @PreAuthorize("@ss.hasPermi('system:role:list')") + @GetMapping("/grants") + public TableDataInfo grants(CustomerCreditGrant query) + { + startPage(); + List list = creditQuotaService.listGrants(query); + return getDataTable(list); + } +} 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 index 4b4747ea..97f3e479 100644 --- 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 @@ -1,271 +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 +//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 36db4a25..aff35aaa 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 @@ -31,28 +31,29 @@ public interface ICustomerAccountApi CustomerAccount getAccount(Long customerId, String currency); /** - * 事务内行锁获取客户CNY账户,不存在则按客户档案额度自动初始化(不处理临时额度过期)。 - * 调用方拿到后如需最新临时额度,可再调 {@link #expireTempCreditIfNeeded}。 + * 事务内行锁获取客户CNY账户,不存在则按客户档案额度自动初始化(不处理授信过期)。 + * 调用方拿到后如需最新额度,可再调 {@link #expireDueCreditsIfNeeded}。 */ CustomerAccount lockCnyAccount(Long customerId); /** - * 临时额度到期自动失效(额度清零 + 回写客户档案 + EXPIRE 额度流水)。 - * 入参必须是同一事务内行锁查出的账户;未过期时不做任何操作。 + * 授信(信用额度/临时额度)到期自动失效:过期项额度清零 + 释放角色额度池 + * + 回写客户档案 + 写 EXPIRE 额度流水。 + * 入参必须是同一事务内行锁查出的账户;均未过期时不做任何操作。 */ - void expireTempCreditIfNeeded(CustomerAccount account); + void expireDueCreditsIfNeeded(CustomerAccount account); /** - * 扫描所有临时额度已到期(extra_credit>0 且失效日期早于今天)的CNY账户, - * 逐户独立事务物化失效(行锁 -> 清零 -> 回写档案 -> EXPIRE流水)。 + * 扫描所有授信已到期(信用或临时额度>0 且失效日期早于今天)的CNY账户, + * 逐户独立事务物化失效(行锁 -> 清零 -> 释放角色池 -> 回写档案 -> EXPIRE流水)。 * 单户失败仅记录日志不影响其他户;定时任务每日调用,应用启动后也会执行一次。 * * @return 实际发生失效的账户数 */ - int expireAllDueTempCredits(); + int expireAllDueCredits(); /** - * 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY,档案有值按档案), + * 新建客户时初始化默认CNY账户(余额0,信用额度默认0,档案有值按档案), * 写 INIT 额度流水;CNY账户已存在时不重复创建。 */ int initDefaultCnyAccount(Long customerId); 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 5119fbdb..6ac918bc 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 @@ -49,8 +49,8 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi /** 本位币币种 */ private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY; - /** 新客户默认总额度(CNY) */ - private static final BigDecimal DEFAULT_CREDIT = new BigDecimal("1000"); + /** 新客户默认总额度(CNY):默认0,信用额度一律通过「申请-审批」分配 */ + private static final BigDecimal DEFAULT_CREDIT = BigDecimal.ZERO; /** 金额保留位数 */ private static final int MONEY_SCALE = 2; @@ -60,6 +60,10 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi @Autowired private BmsCustomerMapper bmsCustomerMapper; + /** 角色额度池台账:授信到期释放角色池 */ + @Autowired + private com.ruoyi.customer.customermoney.service.ICreditQuotaService creditQuotaService; + @Autowired private PlatformTransactionManager transactionManager; @@ -113,16 +117,16 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi throw new ServiceException("客户不存在"); } BigDecimal initCredit = nz(customer.getCreditEd()); - if (initCredit.compareTo(BigDecimal.ZERO) <= 0) + if (initCredit.compareTo(BigDecimal.ZERO) < 0) { - initCredit = DEFAULT_CREDIT; + initCredit = BigDecimal.ZERO; } BigDecimal initTemp = nz(customer.getTempEd()); account = createCnyAccount(customerId, initCredit, initTemp, username); // 档案额度与实际初始额度保持一致 bmsCustomerMapper.updateCustomerCredit(customerId, initCredit, initTemp, username); writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT, - initCredit, null, null, username, "新客户默认初始化额度1000 CNY"); + initCredit, null, null, username, "新客户初始化账户,默认信用额度0(需通过额度申请审批分配)"); if (initTemp.compareTo(BigDecimal.ZERO) > 0) { writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP, @@ -132,9 +136,9 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi } @Override - public int expireAllDueTempCredits() + public int expireAllDueCredits() { - List customerIds = customerMoneyMapper.selectExpiredTempCreditCustomerIds(); + List customerIds = customerMoneyMapper.selectExpiredCreditCustomerIds(); if (customerIds == null || customerIds.isEmpty()) { return 0; @@ -142,17 +146,18 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi int expired = 0; for (Long customerId : customerIds) { - // 每户独立事务:行锁 -> 失效清零 -> 回写档案 -> EXPIRE流水,单户失败不影响其他户 + // 每户独立事务:行锁 -> 失效清零 -> 释放角色池 -> 回写档案 -> EXPIRE流水,单户失败不影响其他户 try { Boolean changed = requireNewTxTemplate.execute(status -> { CustomerAccount locked = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY); - if (locked == null || !isTempCreditExpired(locked)) + if (locked == null + || (!isTempCreditExpired(locked) && !isCreditExpired(locked))) { // 可能被其他节点/入口抢先处理 return Boolean.FALSE; } - expireTempCreditIfNeeded(locked); + expireDueCreditsIfNeeded(locked); return Boolean.TRUE; }); if (Boolean.TRUE.equals(changed)) @@ -162,25 +167,39 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi } catch (Exception e) { - log.error("客户{}临时额度到期自动失效失败", customerId, e); + log.error("客户{}授信到期自动失效失败", customerId, e); } } - log.info("临时额度到期批量失效完成,扫描{}户,实际失效{}户", customerIds.size(), expired); + log.info("授信到期批量失效完成,扫描{}户,实际失效{}户", customerIds.size(), expired); return expired; } @Override - public void expireTempCreditIfNeeded(CustomerAccount account) + public void expireDueCreditsIfNeeded(CustomerAccount account) { - if (account == null || !isTempCreditExpired(account)) + if (account == null) + { + return; + } + boolean creditExpired = isCreditExpired(account); + boolean tempExpired = isTempCreditExpired(account); + if (!creditExpired && !tempExpired) { return; } // 定时任务场景无登录态,操作人回落为 system(人工触发的入口仍记录实际操作人) String username = currentUsername(); Long userId = currentUserId(); - BigDecimal before = nz(account.getExtraCredit()); - account.setExtraCredit(BigDecimal.ZERO); + BigDecimal beforeCredit = nz(account.getTotalCredit()); + BigDecimal beforeExtra = nz(account.getExtraCredit()); + if (creditExpired) + { + account.setTotalCredit(BigDecimal.ZERO); + } + if (tempExpired) + { + account.setExtraCredit(BigDecimal.ZERO); + } account.setUpdateBy(username); int rows = customerMoneyMapper.updateAccountCredit(account); if (rows == 0) @@ -189,25 +208,49 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi } account.setVersion(account.getVersion() + 1); bmsCustomerMapper.updateCustomerCredit(account.getCustomerId(), - nz(account.getTotalCredit()), BigDecimal.ZERO, username); + nz(account.getTotalCredit()), nz(account.getExtraCredit()), username); + // 释放对应角色额度池(无台账背书的历史额度释放金额为0,不影响池余额) + if (creditExpired) + { + creditQuotaService.releaseAll(account.getCustomerId(), + CustomerCreditFlow.CREDIT_TYPE_CREDIT, username); + writeExpireFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT, beforeCredit, + account.getCreditEffectiveDate(), account.getCreditExpireDate(), + username, userId, "信用额度到期自动失效"); + } + if (tempExpired) + { + creditQuotaService.releaseAll(account.getCustomerId(), + CustomerCreditFlow.CREDIT_TYPE_TEMP, username); + writeExpireFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP, beforeExtra, + account.getExtraEffectiveDate(), account.getExtraExpireDate(), + username, userId, "临时额度到期自动失效"); + } + } + + /** 到期失效写 EXPIRE 额度流水 */ + private void writeExpireFlow(CustomerAccount account, String creditType, BigDecimal before, + Date effectiveDate, Date expireDate, + String username, Long userId, String reason) + { CustomerCreditFlow flow = new CustomerCreditFlow(); flow.setFlowNo(generateNo("EDLS")); flow.setCustomerId(account.getCustomerId()); flow.setAccountId(account.getId()); - flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_TEMP); + flow.setCreditType(creditType); flow.setChangeType(CustomerCreditFlow.CHANGE_EXPIRE); flow.setDirection(-1); flow.setBeforeAmount(before); flow.setChangeAmount(before); flow.setAfterAmount(BigDecimal.ZERO); flow.setUsedCredit(nz(account.getUsedCredit())); - flow.setEffectiveDate(account.getExtraEffectiveDate()); - flow.setExpireDate(account.getExtraExpireDate()); - flow.setReason("临时额度到期自动失效"); + flow.setEffectiveDate(effectiveDate); + flow.setExpireDate(expireDate); + flow.setReason(reason); flow.setOperatorId(userId); flow.setCreateBy(username); - flow.setRemark("系统在访问账户时检测到临时额度已过失效日期,自动置零"); + flow.setRemark("系统在访问账户或定时任务中检测到额度已过失效日期,自动置零"); flow.setDelFlag("0"); customerMoneyMapper.insertCreditFlow(flow); } @@ -473,7 +516,7 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi if (BASE_CURRENCY.equals(currency)) { CustomerAccount account = getOrInitCnyAccount(customerId, username); - expireTempCreditIfNeeded(account); + expireDueCreditsIfNeeded(account); return account; } return customerMoneyMapper.selectAccountForUpdate(customerId, currency); @@ -488,7 +531,7 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi if (BASE_CURRENCY.equals(currency)) { account = getOrInitCnyAccount(customerId, username); - expireTempCreditIfNeeded(account); + expireDueCreditsIfNeeded(account); } else { @@ -676,21 +719,24 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi BigDecimal rawExtra = account != null ? nz(account.getExtraCredit()) : BigDecimal.ZERO; BigDecimal usedCredit = account != null ? nz(account.getUsedCredit()) : BigDecimal.ZERO; - // 临时额度已过期则视为0(仅内存计算,不物化DB;由定时任务/真实资金入口落库) + // 已过期的信用/临时额度视为0(仅内存计算,不物化DB;由定时任务/真实资金入口落库) + boolean creditExpiredPending = isCreditExpired(account); boolean expiredPending = isTempCreditExpired(account); + BigDecimal effectiveTotal = creditExpiredPending ? BigDecimal.ZERO : totalCredit; 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.setEffectiveTotalCredit(effectiveTotal); vo.setExtraCredit(rawExtra); vo.setEffectiveExtraCredit(effectiveExtra); vo.setUsedCredit(usedCredit); - BigDecimal available = totalCredit.add(effectiveExtra).subtract(usedCredit); + BigDecimal available = effectiveTotal.add(effectiveExtra).subtract(usedCredit); vo.setAvailableCredit(available); boolean overdue = available.compareTo(BigDecimal.ZERO) < 0; vo.setOverdue(overdue); - vo.setExpiredTempCreditPending(expiredPending); + vo.setExpiredTempCreditPending(expiredPending || creditExpiredPending); boolean disabled = "1".equals(cust.getIsEnable()); if (disabled && overdue) @@ -779,6 +825,16 @@ public class CustomerAccountApiImpl implements ICustomerAccountApi .atZone(java.time.ZoneId.systemDefault()).toLocalDate()); } + /** 信用额度是否已过期:口径同临时额度(失效日当天仍有效) */ + private boolean isCreditExpired(CustomerAccount account) + { + return account != null + && nz(account.getTotalCredit()).compareTo(BigDecimal.ZERO) > 0 + && account.getCreditExpireDate() != null + && LocalDate.now().isAfter(account.getCreditExpireDate().toInstant() + .atZone(java.time.ZoneId.systemDefault()).toLocalDate()); + } + private String resolveCurrency(String currency) { return StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java index 4f1b6bf2..0a24b999 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java @@ -34,9 +34,17 @@ public class CustomerAccount extends BaseEntity /** 冻结余额 */ private BigDecimal frozenBalance; - /** 总额度 */ + /** 总额度(信用额度,默认0;配置有效期后到期次日自动清零) */ private BigDecimal totalCredit; + /** 信用额度生效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date creditEffectiveDate; + + /** 信用额度失效日期(空=长期;到期次日自动清零) */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date creditExpireDate; + /** 额外额度(临时额度) */ private BigDecimal extraCredit; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditApply.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditApply.java index 0ce23ce7..6d1c8d0a 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditApply.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditApply.java @@ -79,6 +79,9 @@ public class CustomerCreditApply extends BaseEntity /** 审批人ID */ private Long approveUserId; + /** 审批占用的额度池角色ID(NULL=超管审批不占池;调减/到期释放时不消费池) */ + private Long approveRoleId; + /** 审批时间 */ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date approveTime; @@ -99,4 +102,7 @@ public class CustomerCreditApply extends BaseEntity /** 客户名称 */ private String customerName; + + /** 额度池角色名称(关联展示) */ + private String approveRoleName; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditFlow.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditFlow.java index 25ce1c4c..021e9110 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditFlow.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditFlow.java @@ -101,6 +101,9 @@ public class CustomerCreditFlow extends BaseEntity /** 操作人ID */ private Long operatorId; + /** 额度池角色ID(ADJUST 审批调整时记录本次占用/释放的角色池;NULL=超管/系统) */ + private Long roleId; + /** 删除标志(0存在 1删除) */ private String delFlag; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditGrant.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditGrant.java new file mode 100644 index 00000000..a9b9d965 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditGrant.java @@ -0,0 +1,90 @@ +package com.ruoyi.customer.customermoney.domain; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 客户授信额度分配台账 customer_credit_grant + * 审批通过的净增额度占一行(占用角色额度池);调减/到期按 FIFO 核销行余额。 + * 角色池已用 = SUM(remaining WHERE role_id=? AND status='0') + * + * @author ruoyi + * @date 2026-09-20 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class CustomerCreditGrant extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 占用中 */ + public static final String STATUS_ACTIVE = "0"; + /** 已释放 */ + public static final String STATUS_RELEASED = "1"; + + /** 台账ID */ + private Long grantId; + + /** 客户ID */ + private Long customerId; + + /** 客户账户ID */ + private Long accountId; + + /** 占用的额度池角色ID(NULL=超管审批/不占池) */ + private Long roleId; + + /** 审批人ID */ + private Long approveUserId; + + /** 额度申请单ID */ + private Long applyId; + + /** 额度流水ID */ + private Long flowId; + + /** 额度类型:CREDIT信用额度 TEMP临时额度 */ + private String creditType; + + /** 原始占用池金额 */ + private BigDecimal amount; + + /** 仍占用池的待释放余额 */ + private BigDecimal remaining; + + /** 生效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date effectiveDate; + + /** 失效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date expireDate; + + /** 状态:0占用中 1已释放 */ + private String status; + + /** 全部释放时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date releaseTime; + + /** 删除标志 */ + private String delFlag; + + /* -------- 展示字段 -------- */ + + /** 客户编码 */ + private String customerCode; + + /** 客户名称 */ + private String customerName; + + /** 角色名称 */ + private String roleName; + + /** 审批人用户名 */ + private String approveBy; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/SysRoleCreditQuota.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/SysRoleCreditQuota.java new file mode 100644 index 00000000..f25b996c --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/SysRoleCreditQuota.java @@ -0,0 +1,29 @@ +package com.ruoyi.customer.customermoney.domain; + +import java.math.BigDecimal; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 角色可分配授信额度池 sys_role_credit_quota + * 一个角色一行;没有记录视为额度0(该角色用户不能审批任何增量授信) + * + * @author ruoyi + * @date 2026-09-20 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SysRoleCreditQuota extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 角色ID */ + private Long roleId; + + /** 可分配额度总额(CNY) */ + private BigDecimal totalQuota; + + /** 乐观锁版本号 */ + private Integer version; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CreditQuotaMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CreditQuotaMapper.java new file mode 100644 index 00000000..3e99d497 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CreditQuotaMapper.java @@ -0,0 +1,53 @@ +package com.ruoyi.customer.customermoney.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.customer.customermoney.domain.CustomerCreditGrant; +import com.ruoyi.customer.customermoney.domain.SysRoleCreditQuota; +import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo; + +import java.math.BigDecimal; + +/** + * 角色额度池 / 授信分配台账 Mapper + * + * @author ruoyi + * @date 2026-09-20 + */ +public interface CreditQuotaMapper +{ + /* ================= 角色额度池 ================= */ + + /** 全部角色的额度池(左连接,未配置额度的角色总额度按0返回;used 由台账 remaining 实时求和) */ + List selectRoleQuotaList(); + + /** 指定用户名下正常角色的额度池(sys_user_role 关联) */ + List selectRoleQuotaListByUserId(@Param("userId") Long userId); + + /** 行锁取额度池配置(不存在返回null) */ + SysRoleCreditQuota selectQuotaForUpdate(@Param("roleId") Long roleId); + + /** 角色池已分配额(占用中台账 remaining 合计;调用前需先持有该池行锁) */ + BigDecimal sumActiveRemaining(@Param("roleId") Long roleId); + + /** 新建额度池配置 */ + int insertQuota(SysRoleCreditQuota quota); + + /** 修改额度池总额度(乐观锁) */ + int updateQuota(SysRoleCreditQuota quota); + + /* ================= 授信分配台账 ================= */ + + /** 台账查询(带客户名/角色名) */ + List selectGrantList(CustomerCreditGrant query); + + /** 行锁取某客户某额度类型的占用中台账(FIFO:按 grant_id 升序) */ + List selectActiveGrantsForUpdate(@Param("customerId") Long customerId, + @Param("creditType") String creditType); + + /** 新增台账 */ + int insertGrant(CustomerCreditGrant grant); + + /** 更新台账剩余余额/状态(乐观条件 remaining 不变,防并发核销) */ + int updateGrantRemaining(CustomerCreditGrant grant); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java index 6d300560..3c8dc298 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java @@ -29,10 +29,10 @@ public interface CustomerMoneyMapper List selectAccountListByCustomerId(@Param("customerId") Long customerId); /** - * 扫描临时额度已到期的CNY账户客户ID(extra_credit>0 且失效日期早于今天;失效日当天仍有效)。 + * 扫描授信已到期的CNY账户客户ID(信用或临时额度失效日早于今天;失效日当天仍有效)。 * 供定时任务批量物化失效使用,结果集不加锁,逐户处理时再行锁。 */ - List selectExpiredTempCreditCustomerIds(); + List selectExpiredCreditCustomerIds(); /** 新增账户 */ int insertAccount(CustomerAccount account); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICreditQuotaService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICreditQuotaService.java new file mode 100644 index 00000000..7cc6d3ff --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICreditQuotaService.java @@ -0,0 +1,50 @@ +package com.ruoyi.customer.customermoney.service; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import com.ruoyi.customer.customermoney.domain.CustomerAccount; +import com.ruoyi.customer.customermoney.domain.CustomerCreditGrant; +import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo; + +/** + * 角色额度池 Service + * + * @author ruoyi + * @date 2026-09-20 + */ +public interface ICreditQuotaService +{ + /** 全部角色额度池(管理员配置用) */ + List listRoleQuotas(); + + /** 当前用户可使用的角色额度池(审批选池用;超管返回空列表由调用方放行) */ + List myPools(Long userId); + + /** 设置角色可分配额度总额(不能小于该池已分配额) */ + void setRoleQuota(Long roleId, BigDecimal totalQuota, String remark); + + /** 额度池占用台账查询 */ + List listGrants(CustomerCreditGrant query); + + /** + * 审批净增额度:占用角色额度池并写台账。 + * roleId 为 null(超管审批)时台账角色为空、不占用任何池。 + * 必须在客户账户行锁之后调用;内部对角色池加行锁防并发超配。 + */ + void consume(CustomerAccount account, String creditType, BigDecimal amount, + Date effectiveDate, Date expireDate, Long roleId, + Long applyId, Long flowId, String username, Long userId); + + /** + * 审批调减:按 FIFO 核销该客户该类型的占用台账,释放对应角色池。 + * 历史无台账背书的额度(建档直配/超管授予)核销时不释放池。 + * 返回实际从池中释放的金额(可能小于 amount)。 + */ + BigDecimal releaseFifo(Long customerId, String creditType, BigDecimal amount, String username); + + /** + * 到期自动失效:释放该客户该类型全部占用台账,返回释放总额。 + */ + BigDecimal releaseAll(Long customerId, String creditType, String username); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java index 27365aab..31079364 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java @@ -19,7 +19,7 @@ public interface ICustomerMoneyService List selectAccountList(Long customerId); /** - * 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY),并写 INIT 额度流水。 + * 新建客户时初始化默认CNY账户(余额0,信用额度默认0),并写 INIT 额度流水。 * 已存在CNY账户时不重复创建。 * * @param customerId 客户ID @@ -60,8 +60,9 @@ public interface ICustomerMoneyService /** * 审批通过额度调整申请:PENDING -> APPROVED。 * 事务内重新锁定CNY账户并按审批时的实际额度计算/校验,通过后调整额度、回写客户档案、写额度流水。 + * 净增额度需占用 approveRoleId 对应角色额度池(超管传 null 不占池;净减/到期释放不占池)。 */ - int approveCreditApply(Long applyId, String approveRemark); + int approveCreditApply(Long applyId, String approveRemark, Long approveRoleId); /** 审批驳回:PENDING -> REJECTED,驳回原因必填 */ int rejectCreditApply(Long applyId, String rejectReason); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CreditQuotaServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CreditQuotaServiceImpl.java new file mode 100644 index 00000000..d0cbde6a --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CreditQuotaServiceImpl.java @@ -0,0 +1,290 @@ +package com.ruoyi.customer.customermoney.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.customer.customermoney.domain.CustomerAccount; +import com.ruoyi.customer.customermoney.domain.CustomerCreditGrant; +import com.ruoyi.customer.customermoney.domain.SysRoleCreditQuota; +import com.ruoyi.customer.customermoney.mapper.CreditQuotaMapper; +import com.ruoyi.customer.customermoney.service.ICreditQuotaService; +import com.ruoyi.customer.customermoney.vo.RoleCreditQuotaVo; +import com.ruoyi.system.mapper.SysRoleMapper; + +/** + * 角色额度池 Service实现 + * + * @author ruoyi + * @date 2026-09-20 + */ +@Service +public class CreditQuotaServiceImpl implements ICreditQuotaService +{ + private static final int MONEY_SCALE = 2; + + @Autowired + private CreditQuotaMapper creditQuotaMapper; + + @Autowired + private SysRoleMapper sysRoleMapper; + + @Override + public List listRoleQuotas() + { + List list = creditQuotaMapper.selectRoleQuotaList(); + list.forEach(this::fillAvailable); + return list; + } + + @Override + public List myPools(Long userId) + { + if (userId == null) + { + throw new ServiceException("无法获取当前登录用户"); + } + List list = creditQuotaMapper.selectRoleQuotaListByUserId(userId); + list.forEach(this::fillAvailable); + return list; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void setRoleQuota(Long roleId, BigDecimal totalQuota, String remark) + { + if (roleId == null) + { + throw new ServiceException("角色ID不能为空"); + } + SysRole role = sysRoleMapper.selectRoleById(roleId); + if (role == null || "2".equals(role.getDelFlag())) + { + throw new ServiceException("角色不存在或已删除"); + } + BigDecimal total = nz(totalQuota).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + if (total.compareTo(BigDecimal.ZERO) < 0) + { + throw new ServiceException("可分配额度总额不能为负数"); + } + String username = SecurityUtils.getUsername(); + + SysRoleCreditQuota quota = creditQuotaMapper.selectQuotaForUpdate(roleId); + if (quota == null) + { + SysRoleCreditQuota newQuota = new SysRoleCreditQuota(); + newQuota.setRoleId(roleId); + newQuota.setTotalQuota(total); + newQuota.setVersion(0); + newQuota.setCreateBy(username); + newQuota.setRemark(StringUtils.hasText(remark) ? remark.trim() : null); + creditQuotaMapper.insertQuota(newQuota); + return; + } + BigDecimal used = nz(creditQuotaMapper.sumActiveRemaining(roleId)); + if (total.compareTo(used) < 0) + { + throw new ServiceException("可分配额度总额不能小于该角色已分配额度:" + used.toPlainString() + " CNY"); + } + quota.setTotalQuota(total); + quota.setUpdateBy(username); + quota.setRemark(StringUtils.hasText(remark) ? remark.trim() : null); + if (creditQuotaMapper.updateQuota(quota) == 0) + { + throw new ServiceException("额度池已被其他操作变动,请刷新后重试"); + } + } + + @Override + public List listGrants(CustomerCreditGrant query) + { + return creditQuotaMapper.selectGrantList(query); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void consume(CustomerAccount account, String creditType, BigDecimal amount, + Date effectiveDate, Date expireDate, Long roleId, + Long applyId, Long flowId, String username, Long userId) + { + if (account == null || account.getId() == null) + { + throw new ServiceException("客户账户不存在,无法分配额度"); + } + BigDecimal delta = nz(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + if (delta.compareTo(BigDecimal.ZERO) <= 0) + { + return; + } + CustomerCreditGrant grant = new CustomerCreditGrant(); + grant.setCustomerId(account.getCustomerId()); + grant.setAccountId(account.getId()); + grant.setApproveUserId(userId); + grant.setApplyId(applyId); + grant.setFlowId(flowId); + grant.setCreditType(creditType); + grant.setAmount(delta); + grant.setRemaining(delta); + grant.setEffectiveDate(effectiveDate); + grant.setExpireDate(expireDate); + grant.setStatus(CustomerCreditGrant.STATUS_ACTIVE); + grant.setCreateBy(username); + + if (roleId == null) + { + // 超管审批:不占用任何角色池 + creditQuotaMapper.insertGrant(grant); + return; + } + // 校验角色归属与状态 + SysRole role = sysRoleMapper.selectRoleById(roleId); + if (role == null || "2".equals(role.getDelFlag()) || !"0".equals(role.getStatus())) + { + throw new ServiceException("额度池角色不存在或已停用"); + } + List myRoleIds = sysRoleMapper.selectRoleListByUserId(userId) + .stream().filter(id -> id != null && id.equals(roleId)).collect(Collectors.toList()); + if (myRoleIds.isEmpty()) + { + throw new ServiceException("不能使用不属于自己的角色额度池"); + } + // 池行锁 -> 已分配实时汇总 -> 容量校验,防并发超配 + SysRoleCreditQuota quota = creditQuotaMapper.selectQuotaForUpdate(roleId); + if (quota == null) + { + throw new ServiceException("角色【" + role.getRoleName() + "】未配置可分配额度总额,请联系管理员"); + } + BigDecimal used = nz(creditQuotaMapper.sumActiveRemaining(roleId)); + BigDecimal available = nz(quota.getTotalQuota()).subtract(used); + if (available.compareTo(delta) < 0) + { + throw new ServiceException("角色【" + role.getRoleName() + "】额度池不足:总额度 " + + nz(quota.getTotalQuota()).toPlainString() + ",已分配 " + used.toPlainString() + + ",剩余 " + available.toPlainString() + ",本次需分配 " + delta.toPlainString()); + } + grant.setRoleId(roleId); + creditQuotaMapper.insertGrant(grant); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public BigDecimal releaseFifo(Long customerId, String creditType, BigDecimal amount, String username) + { + BigDecimal need = nz(amount); + if (need.compareTo(BigDecimal.ZERO) <= 0) + { + return BigDecimal.ZERO; + } + List grants = creditQuotaMapper.selectActiveGrantsForUpdate(customerId, creditType); + if (grants.isEmpty()) + { + return BigDecimal.ZERO; + } + lockQuotaRows(grants); + + BigDecimal released = BigDecimal.ZERO; + Date now = new Date(); + for (CustomerCreditGrant grant : grants) + { + if (need.compareTo(BigDecimal.ZERO) <= 0) + { + break; + } + BigDecimal remaining = nz(grant.getRemaining()); + if (remaining.compareTo(BigDecimal.ZERO) <= 0) + { + continue; + } + BigDecimal take = remaining.min(need); + BigDecimal after = remaining.subtract(take).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + grant.setRemaining(after); + grant.setUpdateBy(username); + if (after.compareTo(BigDecimal.ZERO) == 0) + { + grant.setStatus(CustomerCreditGrant.STATUS_RELEASED); + grant.setReleaseTime(now); + } + else + { + grant.setStatus(CustomerCreditGrant.STATUS_ACTIVE); + grant.setReleaseTime(null); + } + creditQuotaMapper.updateGrantRemaining(grant); + need = need.subtract(take); + released = released.add(take); + } + return released.setScale(MONEY_SCALE, RoundingMode.HALF_UP); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public BigDecimal releaseAll(Long customerId, String creditType, String username) + { + List grants = creditQuotaMapper.selectActiveGrantsForUpdate(customerId, creditType); + if (grants.isEmpty()) + { + return BigDecimal.ZERO; + } + lockQuotaRows(grants); + + BigDecimal released = BigDecimal.ZERO; + Date now = new Date(); + for (CustomerCreditGrant grant : grants) + { + BigDecimal remaining = nz(grant.getRemaining()); + if (remaining.compareTo(BigDecimal.ZERO) <= 0) + { + continue; + } + grant.setRemaining(BigDecimal.ZERO); + grant.setStatus(CustomerCreditGrant.STATUS_RELEASED); + grant.setReleaseTime(now); + grant.setUpdateBy(username); + creditQuotaMapper.updateGrantRemaining(grant); + released = released.add(remaining); + } + return released.setScale(MONEY_SCALE, RoundingMode.HALF_UP); + } + + /** + * 对台账涉及的非空角色池按 role_id 升序加行锁, + * 保证与池占用(consume)互斥且避免多角色交叉死锁。 + */ + private void lockQuotaRows(List grants) + { + List roleIds = grants.stream() + .map(CustomerCreditGrant::getRoleId) + .filter(id -> id != null) + .distinct() + .sorted(Comparator.naturalOrder()) + .collect(Collectors.toList()); + for (Long roleId : roleIds) + { + // 池配置理论上一定存在(占用时已校验);若被误删则空过,不影响台账核销 + creditQuotaMapper.selectQuotaForUpdate(roleId); + } + } + + private void fillAvailable(RoleCreditQuotaVo vo) + { + BigDecimal total = nz(vo.getTotalQuota()); + BigDecimal used = nz(vo.getUsedQuota()); + vo.setTotalQuota(total); + vo.setUsedQuota(used); + vo.setAvailableQuota(total.subtract(used).setScale(MONEY_SCALE, RoundingMode.HALF_UP)); + } + + private BigDecimal nz(BigDecimal value) + { + return value == null ? BigDecimal.ZERO : value; + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java index e440cff5..ea720031 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java @@ -28,6 +28,7 @@ import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow; import com.ruoyi.customer.customermoney.domain.CustomerReceipt; import com.ruoyi.customer.customermoney.mapper.CustomerCreditApplyMapper; import com.ruoyi.customer.customermoney.mapper.CustomerMoneyMapper; +import com.ruoyi.customer.customermoney.service.ICreditQuotaService; import com.ruoyi.customer.customermoney.service.ICustomerMoneyService; import com.ruoyi.system.domain.BmsExchangeRate; import com.ruoyi.system.service.IBmsExchangeRateService; @@ -64,19 +65,24 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService @Autowired private ICustomerAccountApi customerAccountApi; + /** 角色额度池:审批增量占用/调减与到期释放 */ + @Autowired + private ICreditQuotaService creditQuotaService; + @Override @Transactional(rollbackFor = Exception.class) public List selectAccountList(Long customerId) { List list = customerMoneyMapper.selectAccountListByCustomerId(customerId); - // 查询时顺带处理临时额度到期自动失效(仅CNY账户),保证卡片展示为最新值 + // 查询时顺带处理授信到期自动失效(仅CNY账户),保证卡片展示为最新值 for (int i = 0; i < list.size(); i++) { CustomerAccount account = list.get(i); - if (BASE_CURRENCY.equals(account.getCurrency()) && isTempCreditExpired(account)) + if (BASE_CURRENCY.equals(account.getCurrency()) + && (isTempCreditExpired(account) || isCreditExpired(account))) { CustomerAccount locked = customerAccountApi.lockCnyAccount(customerId); - customerAccountApi.expireTempCreditIfNeeded(locked); + customerAccountApi.expireDueCreditsIfNeeded(locked); if (locked != null) { list.set(i, locked); @@ -331,10 +337,11 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService /** * 执行额度调整(额度申请审批通过后调用)。 - * 锁CNY账户 -> 按审批时实际额度计算并校验 -> 乐观锁更新 -> 回写客户档案 -> 写额度流水。 + * 锁CNY账户 -> 按审批时实际额度计算并校验 -> 乐观锁更新 -> 回写客户档案 -> 写额度流水 -> 角色额度池占用/释放。 + * roleId 为本次审批选择的额度池角色(超管/净减时可为null);applyId 为审批申请单ID。 * 返回已落库的额度流水(含ID)。 */ - private CustomerCreditFlow executeAdjustCredit(CustomerCreditFlow cmd) + private CustomerCreditFlow executeAdjustCredit(CustomerCreditFlow cmd, Long roleId, Long applyId) { String username = SecurityUtils.getUsername(); Long userId = SecurityUtils.getUserId(); @@ -365,18 +372,18 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService } after = after.setScale(MONEY_SCALE, RoundingMode.HALF_UP); - // 3. 临时额度有效期处理(不允许预约未来生效,失效日必须晚于生效日) + // 3. 有效期处理:信用/临时同口径——不允许预约未来生效;信用额度失效日期必填,临时额度空=长期 Date effectiveDate = null; Date expireDate = null; + LocalDate today = LocalDate.now(); + LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today; + effectiveDate = toSqlDate(eff); + if (cmd.getExpireDate() != null) + { + expireDate = toSqlDate(toLocalDate(cmd.getExpireDate())); + } if (isTemp) { - LocalDate today = LocalDate.now(); - LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today; - effectiveDate = toSqlDate(eff); - if (cmd.getExpireDate() != null) - { - expireDate = toSqlDate(toLocalDate(cmd.getExpireDate())); - } account.setExtraCredit(after); account.setExtraEffectiveDate(effectiveDate); account.setExtraExpireDate(expireDate); @@ -384,6 +391,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService else { account.setTotalCredit(after); + account.setCreditEffectiveDate(effectiveDate); + account.setCreditExpireDate(expireDate); } account.setUpdateBy(username); @@ -417,10 +426,29 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService flow.setReason(reason); flow.setAdjustMode(adjustMode); flow.setOperatorId(userId); + // 非超管审批且未指定角色池的净增申请不允许通过(在 consume 中强校验) + flow.setRoleId(SecurityUtils.isAdmin(userId) ? null : roleId); flow.setCreateBy(username); flow.setRemark(cmd.getRemark()); flow.setDelFlag("0"); customerMoneyMapper.insertCreditFlow(flow); + + // 7. 角色额度池占用/释放(账户行锁已持有;池锁在台账服务内部按角色加锁) + BigDecimal netDelta = after.subtract(before); + if (netDelta.compareTo(BigDecimal.ZERO) > 0) + { + Long poolRoleId = SecurityUtils.isAdmin(userId) ? null : roleId; + if (poolRoleId == null && !SecurityUtils.isAdmin(userId)) + { + throw new ServiceException("请选择本次额度分配占用的角色额度池"); + } + creditQuotaService.consume(account, creditType, netDelta, + effectiveDate, expireDate, poolRoleId, applyId, flow.getId(), username, userId); + } + else if (netDelta.compareTo(BigDecimal.ZERO) < 0) + { + creditQuotaService.releaseFifo(customerId, creditType, netDelta.negate(), username); + } return flow; } @@ -477,7 +505,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService @Override @Transactional(rollbackFor = Exception.class) - public int approveCreditApply(Long applyId, String approveRemark) + public int approveCreditApply(Long applyId, String approveRemark, Long approveRoleId) { if (applyId == null) { @@ -505,7 +533,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService cmd.setRemark(apply.getRemark()); cmd.setEffectiveDate(apply.getEffectiveDate()); cmd.setExpireDate(apply.getExpireDate()); - CustomerCreditFlow flow = executeAdjustCredit(cmd); + CustomerCreditFlow flow = executeAdjustCredit(cmd, approveRoleId, applyId); CustomerCreditApply update = new CustomerCreditApply(); update.setApplyId(applyId); @@ -513,6 +541,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService update.setFlowId(flow.getId()); update.setApproveBy(username); update.setApproveUserId(userId); + // 超管审批不占池;非超管净减时 roleId 为 null 也正常(只释放不占用) + update.setApproveRoleId(SecurityUtils.isAdmin(userId) ? null : approveRoleId); update.setApproveRemark(approveRemark); int rows = creditApplyMapper.updateApproved(update); if (rows == 0) @@ -628,7 +658,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService boolean isTemp = CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType); CustomerAccount account = customerAccountApi.lockCnyAccount(cmd.getCustomerId()); - customerAccountApi.expireTempCreditIfNeeded(account); + customerAccountApi.expireDueCreditsIfNeeded(account); BigDecimal before = isTemp ? nz(account.getExtraCredit()) : nz(account.getTotalCredit()); BigDecimal otherCredit = isTemp ? nz(account.getTotalCredit()) : nz(account.getExtraCredit()); @@ -660,21 +690,23 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService throw new ServiceException("额度未发生变化,无需调整"); } - if (isTemp) + if (!isTemp && cmd.getExpireDate() == null) { - LocalDate today = LocalDate.now(); - LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today; - if (eff.isAfter(today)) + // 信用额度必须设置失效日期(到期自动清零);临时额度不填=长期 + throw new ServiceException("请选择信用额度失效日期"); + } + LocalDate today = LocalDate.now(); + LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today; + if (eff.isAfter(today)) + { + throw new ServiceException("生效日期不能晚于今天,需要未来生效请在生效当天再提交"); + } + if (cmd.getExpireDate() != null) + { + LocalDate exp = toLocalDate(cmd.getExpireDate()); + if (!exp.isAfter(eff)) { - throw new ServiceException("生效日期不能晚于今天,需要未来生效请在生效当天再提交"); - } - if (cmd.getExpireDate() != null) - { - LocalDate exp = toLocalDate(cmd.getExpireDate()); - if (!exp.isAfter(eff)) - { - throw new ServiceException("失效日期必须晚于生效日期"); - } + throw new ServiceException("失效日期必须晚于生效日期"); } } return account; @@ -701,6 +733,15 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService && LocalDate.now().isAfter(toLocalDate(account.getExtraExpireDate())); } + /** 信用额度是否已过期:口径同临时额度(失效日当天仍有效) */ + private boolean isCreditExpired(CustomerAccount account) + { + return account != null + && nz(account.getTotalCredit()).compareTo(BigDecimal.ZERO) > 0 + && account.getCreditExpireDate() != null + && LocalDate.now().isAfter(toLocalDate(account.getCreditExpireDate())); + } + private LocalDate toLocalDate(Date date) { return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate(); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/task/TempCreditExpireTask.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/task/TempCreditExpireTask.java index 172a8570..12e67a7d 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/task/TempCreditExpireTask.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/task/TempCreditExpireTask.java @@ -10,11 +10,11 @@ import org.springframework.stereotype.Component; import com.ruoyi.customer.customermoney.api.ICustomerAccountApi; /** - * 客户临时额度到期自动失效任务。 + * 客户授信(信用额度/临时额度)到期自动失效任务。 * - *

每天 01:30 扫描临时额度已到期(extra_credit>0 且失效日期早于今天)的CNY账户, - * 逐户独立事务物化失效:额度清零 -> 回写客户档案 -> 写 EXPIRE 额度流水。 - * 失效日当天临时额度仍有效,故严格按「早于今天」判断。

+ *

每天 01:30 扫描授信已到期(信用或临时额度>0 且失效日期早于今天)的CNY账户, + * 逐户独立事务物化失效:额度清零 -> 释放角色额度池 -> 回写客户档案 -> 写 EXPIRE 额度流水。 + * 失效日当天额度仍有效,故严格按「早于今天」判断。

* *

除惰性触发(访问资金接口时即查即失效)外,本任务保证展示口径一致: * 客户列表可用额度是账户表现算,不经过惰性入口,需由本任务把到期状态落库。 @@ -51,12 +51,12 @@ public class TempCreditExpireTask implements ApplicationRunner { try { - int count = customerAccountApi.expireAllDueTempCredits(); - log.info("客户临时额度到期失效任务执行完成,失效账户数={}", count); + int count = customerAccountApi.expireAllDueCredits(); + log.info("客户授信到期失效任务执行完成,失效账户数={}", count); } catch (Exception e) { - log.error("客户临时额度到期失效任务执行异常", e); + log.error("客户授信到期失效任务执行异常", e); } } } 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 index 77ea62bf..7ac3303b 100644 --- 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 @@ -44,6 +44,8 @@ public class CustomerAccountStatusVO implements Serializable private BigDecimal frozenBalance; /** 授信总额度(CNY) */ private BigDecimal totalCredit; + /** 授信总额度实际有效值(过期未物化时为0,接口计算得出,不物化DB) */ + private BigDecimal effectiveTotalCredit; /** 临时额度原值(CNY,DB 中 extra_credit 字段) */ private BigDecimal extraCredit; /** 临时额度实际有效值(过期时为0,接口计算得出,不物化DB) */ @@ -78,6 +80,8 @@ public class CustomerAccountStatusVO implements Serializable public void setFrozenBalance(BigDecimal frozenBalance) { this.frozenBalance = frozenBalance; } public BigDecimal getTotalCredit() { return totalCredit; } public void setTotalCredit(BigDecimal totalCredit) { this.totalCredit = totalCredit; } + public BigDecimal getEffectiveTotalCredit() { return effectiveTotalCredit; } + public void setEffectiveTotalCredit(BigDecimal effectiveTotalCredit) { this.effectiveTotalCredit = effectiveTotalCredit; } public BigDecimal getExtraCredit() { return extraCredit; } public void setExtraCredit(BigDecimal extraCredit) { this.extraCredit = extraCredit; } public BigDecimal getEffectiveExtraCredit() { return effectiveExtraCredit; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/vo/RoleCreditQuotaVo.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/vo/RoleCreditQuotaVo.java new file mode 100644 index 00000000..70fbff97 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/vo/RoleCreditQuotaVo.java @@ -0,0 +1,38 @@ +package com.ruoyi.customer.customermoney.vo; + +import java.math.BigDecimal; +import lombok.Data; + +/** + * 角色额度池视图(角色管理/审批选池用) + * + * @author ruoyi + * @date 2026-09-20 + */ +@Data +public class RoleCreditQuotaVo +{ + /** 角色ID */ + private Long roleId; + + /** 角色名称 */ + private String roleName; + + /** 权限字符 */ + private String roleKey; + + /** 角色状态(0正常 1停用) */ + private String status; + + /** 可分配额度总额;未配置记录时为0 */ + private BigDecimal totalQuota; + + /** 已分配(占用中台账余额合计) */ + private BigDecimal usedQuota; + + /** 剩余可分配 */ + private BigDecimal availableQuota; + + /** 备注 */ + private String remark; +} diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/customer/CreditQuotaMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/customer/CreditQuotaMapper.xml new file mode 100644 index 00000000..f4b9a3b9 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/customer/CreditQuotaMapper.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + insert into sys_role_credit_quota (role_id, total_quota, version, create_by, create_time, remark) + values (#{roleId}, #{totalQuota}, 0, #{createBy}, sysdate(), #{remark}) + on duplicate key update total_quota = values(total_quota), + remark = values(remark), + update_by = values(create_by), + update_time = now() + + + + update sys_role_credit_quota + set total_quota = #{totalQuota}, + version = version + 1, + update_by = #{updateBy}, + update_time = now(), + remark = #{remark} + where role_id = #{roleId} and version = #{version} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select g.grant_id, g.customer_id, g.account_id, g.role_id, g.approve_user_id, g.apply_id, g.flow_id, + g.credit_type, g.amount, g.remaining, g.effective_date, g.expire_date, g.status, g.release_time, + g.create_by, g.create_time, g.update_by, g.update_time, g.remark, g.del_flag, + c.customer_code, c.customer_name, + r.role_name, + u.user_name as approve_by + from customer_credit_grant g + left join bms_customer c on c.customer_id = g.customer_id + left join sys_role r on r.role_id = g.role_id + left join sys_user u on u.user_id = g.approve_user_id + + + + + + + + insert into customer_credit_grant ( + customer_id, account_id, role_id, approve_user_id, apply_id, flow_id, + credit_type, amount, remaining, effective_date, expire_date, status, + create_by, create_time, remark, del_flag + ) values ( + #{customerId}, #{accountId}, #{roleId}, #{approveUserId}, #{applyId}, #{flowId}, + #{creditType}, #{amount}, #{remaining}, #{effectiveDate}, #{expireDate}, #{status}, + #{createBy}, sysdate(), #{remark}, '0' + ) + + + + update customer_credit_grant + set remaining = #{remaining}, + status = #{status}, + release_time = #{releaseTime}, + update_by = #{updateBy}, + update_time = now() + where grant_id = #{grantId} + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerCreditApplyMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerCreditApplyMapper.xml index aac36186..dcdb9135 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerCreditApplyMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerCreditApplyMapper.xml @@ -21,6 +21,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + @@ -38,11 +40,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select a.apply_id, a.apply_no, a.customer_id, a.account_id, a.credit_type, a.adjust_mode, a.change_amount, a.effective_date, a.expire_date, a.reason, a.status, a.apply_by, a.apply_user_id, a.apply_time, - a.approve_by, a.approve_user_id, a.approve_time, a.approve_remark, a.flow_id, + a.approve_by, a.approve_user_id, a.approve_role_id, a.approve_time, a.approve_remark, a.flow_id, a.create_by, a.create_time, a.update_by, a.update_time, a.remark, a.del_flag, - c.customer_code, c.customer_name + c.customer_code, c.customer_name, + r.role_name as approve_role_name from customer_credit_apply a left join bms_customer c on c.customer_id = a.customer_id + left join sys_role r on r.role_id = a.approve_role_id - - + select distinct customer_id from customer_account where del_flag = '0' and currency = 'CNY' - and extra_credit > 0 - and extra_expire_date is not null - and extra_expire_date < CURDATE() + and ( + (total_credit > 0 and credit_expire_date is not null and credit_expire_date < CURDATE()) + or + (extra_credit > 0 and extra_expire_date is not null and extra_expire_date < CURDATE()) + ) - + update customer_account set total_credit = #{totalCredit}, + credit_effective_date = #{creditEffectiveDate}, + credit_expire_date = #{creditExpireDate}, extra_credit = #{extraCredit}, extra_effective_date = #{extraEffectiveDate}, extra_expire_date = #{extraExpireDate}, @@ -138,6 +145,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -149,7 +157,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select id, flow_no, customer_id, account_id, credit_type, change_type, biz_type, biz_id, biz_no, direction, before_amount, change_amount, after_amount, used_credit, - effective_date, expire_date, reason, adjust_mode, operator_id, + effective_date, expire_date, reason, adjust_mode, operator_id, role_id, create_by, create_time, update_by, update_time, remark, del_flag from customer_credit_flow @@ -192,6 +200,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" reason, adjust_mode, operator_id, + role_id, create_by, remark, del_flag, @@ -215,6 +224,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{reason}, #{adjustMode}, #{operatorId}, + #{roleId}, #{createBy}, #{remark}, #{delFlag}, diff --git a/bms-back/sql/customer+supplier/role_credit_quota.sql b/bms-back/sql/customer+supplier/role_credit_quota.sql new file mode 100644 index 00000000..58aee9ac --- /dev/null +++ b/bms-back/sql/customer+supplier/role_credit_quota.sql @@ -0,0 +1,76 @@ +-- ============================================================= +-- 角色额度池 + 客户信用额度有效期 改造 +-- 1. customer_account 新增信用额度生效/失效日期(与临时额度口径一致,到期次日自动清零) +-- 2. customer_credit_apply 新增 approve_role_id,记录审批占用的额度池角色 +-- 3. customer_credit_flow 新增 role_id,流水留痕额度池归属角色 +-- 4. 新表 sys_role_credit_quota:管理员为角色设置的可分配额度总额(额度池) +-- 5. 新表 customer_credit_grant:审批增量额度的占用台账(池余额=该表remaining求和) +-- 2026-09-20 +-- 说明:ALTER 语句非自动幂等,若字段/表已存在,对应语句会报 Duplicate/already exists,忽略即可 +-- ============================================================= +SET NAMES utf8mb4; + +-- ------------------------------------------------------------- +-- 1. customer_account:信用额度有效期 +-- ------------------------------------------------------------- +ALTER TABLE `customer_account` + ADD COLUMN `credit_effective_date` DATE DEFAULT NULL COMMENT '信用额度生效日期' AFTER `total_credit`, + ADD COLUMN `credit_expire_date` DATE DEFAULT NULL COMMENT '信用额度失效日期(空=长期;到期次日清零)' AFTER `credit_effective_date`; + +-- ------------------------------------------------------------- +-- 2. customer_credit_apply / customer_credit_flow:额度池归属 +-- ------------------------------------------------------------- +ALTER TABLE `customer_credit_apply` + ADD COLUMN `approve_role_id` BIGINT(20) DEFAULT NULL COMMENT '审批占用的额度池角色ID(NULL=超管审批不占池)' AFTER `approve_user_id`; + +ALTER TABLE `customer_credit_flow` + ADD COLUMN `role_id` BIGINT(20) DEFAULT NULL COMMENT '额度池角色ID(审批调整流水留痕;NULL=超管/系统)' AFTER `operator_id`; + +-- ------------------------------------------------------------- +-- 3. 角色额度池 +-- 一个角色一行;没有记录视为额度 0(该角色用户不能审批任何增量授信) +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `sys_role_credit_quota` ( + `role_id` BIGINT(20) NOT NULL COMMENT '角色ID(sys_role.role_id)', + `total_quota` DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '可分配额度总额(CNY)', + `version` INT(11) NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + `create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色可分配授信额度池'; + +-- ------------------------------------------------------------- +-- 4. 额度分配台账 +-- 审批通过的净增额度在此占一行(占用角色池);调减/到期按 FIFO 核销行余额。 +-- 角色池已用 = SUM(remaining WHERE role_id=? AND status='0') +-- role_id 为 NULL 表示超管审批(不占池)或历史/建档直配额度。 +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `customer_credit_grant` ( + `grant_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '台账ID', + `customer_id` BIGINT(20) NOT NULL COMMENT '客户ID', + `account_id` BIGINT(20) NOT NULL COMMENT '客户账户ID', + `role_id` BIGINT(20) DEFAULT NULL COMMENT '占用的额度池角色ID(NULL=超管/不占池)', + `approve_user_id` BIGINT(20) DEFAULT NULL COMMENT '审批人ID', + `apply_id` BIGINT(20) DEFAULT NULL COMMENT '额度申请单ID', + `flow_id` BIGINT(20) DEFAULT NULL COMMENT '额度流水ID', + `credit_type` VARCHAR(16) NOT NULL COMMENT '额度类型:CREDIT信用额度 TEMP临时额度', + `amount` DECIMAL(18,2) NOT NULL COMMENT '原始占用池金额', + `remaining` DECIMAL(18,2) NOT NULL COMMENT '仍占用池的待释放余额', + `effective_date` DATE DEFAULT NULL COMMENT '生效日期', + `expire_date` DATE DEFAULT NULL COMMENT '失效日期', + `status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '状态:0占用中 1已释放', + `release_time` DATETIME DEFAULT NULL COMMENT '全部释放时间', + `create_by` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)', + PRIMARY KEY (`grant_id`), + KEY `idx_role_status` (`role_id`, `status`), + KEY `idx_customer_type_status` (`customer_id`, `credit_type`, `status`), + KEY `idx_apply` (`apply_id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='客户授信额度分配台账(角色池占用明细)'; diff --git a/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js b/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js index 9fb1bca4..84404a84 100644 --- a/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js +++ b/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js @@ -86,12 +86,20 @@ export function listCreditApply(query) { }) } -/** 审批通过额度调整申请 */ -export function approveCreditApply(applyId, approveRemark) { +/** 审批通过额度调整申请(data: { approveRemark, approveRoleId },非超管净增须带 approveRoleId) */ +export function approveCreditApply(applyId, data) { return request({ url: '/customersupplier/customermoney/credit/apply/approve/' + applyId, method: 'put', - data: { approveRemark } + data: data || {} + }) +} + +/** 当前登录用户审批时可选择的角色额度池(超管返回空数组) */ +export function listMyCreditPools() { + return request({ + url: '/customersupplier/customermoney/credit/quota/my-pools', + method: 'get' }) } diff --git a/bms-front/src/api/system/role.js b/bms-front/src/api/system/role.js index f13e6f40..b05b3225 100644 --- a/bms-front/src/api/system/role.js +++ b/bms-front/src/api/system/role.js @@ -117,3 +117,20 @@ export function deptTreeSelect(roleId) { method: 'get' }) } + +// 查询全部角色额度池(总额度/已分配/剩余) +export function listRoleCreditQuota() { + return request({ + url: '/system/role/creditQuota/list', + method: 'get' + }) +} + +// 设置角色可分配额度总额 +export function setRoleCreditQuota(data) { + return request({ + url: '/system/role/creditQuota', + method: 'put', + data: data + }) +} diff --git a/bms-front/src/views/customersuppliermanagement/customerinfo/customeredit/index.vue b/bms-front/src/views/customersuppliermanagement/customerinfo/customeredit/index.vue index f1cf57f3..2c95e2ed 100644 --- a/bms-front/src/views/customersuppliermanagement/customerinfo/customeredit/index.vue +++ b/bms-front/src/views/customersuppliermanagement/customerinfo/customeredit/index.vue @@ -129,7 +129,7 @@ - + @@ -772,7 +772,7 @@ function buildEmptyForm() { foreignCurrencyBalance: null, weightThreshold: null, throwRatio: null, - creditEd: 1000, + creditEd: 0, productIds: null, attachmentList: [], companyList: [], diff --git a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue index 87b9b0f0..d9291ba2 100644 --- a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue +++ b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue @@ -48,6 +48,7 @@ v-hasPermi="['customer:credit:adjust']">调整

{{ formatMoney(currentAccount.totalCredit) }}
+
{{ creditPeriodText }}
@@ -58,7 +59,7 @@ v-hasPermi="['customer:credit:adjust']">调整
{{ formatMoney(currentAccount.extraCredit) }}
-
{{ tempCreditPeriodText }}
+
{{ tempCreditPeriodText }}
@@ -423,6 +424,9 @@ + + + @@ -768,7 +772,7 @@ - 信用额度(长期) + 信用额度 临时额度 @@ -794,32 +798,34 @@ /> - + + + + + + + + +
+ {{ creditDialog.form.creditType === 'TEMP' + ? '失效日当天仍有效,次日访问账户时自动清零并记到期流水' + : '信用额度失效日期必填;失效日当天仍有效,次日自动清零,已用额度未回款时将形成欠费拦截' }} +
+
+
+ + + + 本次为净增额度 {{ formatMoney(approveDialog.netDelta) }} CNY,需占用您名下角色的可分配额度总额;额度调减或到期后自动释放回池。 + + + + + + + + + {{ formatMoney(approveDialog.netDelta) }} CNY + + + + + @@ -934,7 +967,7 @@ import axios from 'axios' import { saveAs } from 'file-saver' import { getCustomer } from "@/api/customersuppliermanagement/customerinfo/customer.js" -import { listAccount, listReceipt, getReceipt, addReceipt, confirmReceipt, cancelReceipt, listFlow, listCreditFlow, applyCreditAdjust, listCreditApply, approveCreditApply, rejectCreditApply, cancelCreditApply } from "@/api/customersuppliermanagement/customermoney/customermoney.js" +import { listAccount, listReceipt, getReceipt, addReceipt, confirmReceipt, cancelReceipt, listFlow, listCreditFlow, applyCreditAdjust, listCreditApply, approveCreditApply, rejectCreditApply, cancelCreditApply, listMyCreditPools } from "@/api/customersuppliermanagement/customermoney/customermoney.js" import { listUnsettledBill, listBalanceWriteoff, balanceWriteoff, cancelBalanceWriteoff } from "@/api/customersuppliermanagement/customermoney/customerbill.js" import { convertToBase } from "@/api/data/exchangeRate" import { getToken } from "@/utils/auth" @@ -1087,6 +1120,19 @@ const tempCreditPeriodText = computed(() => { if (a.extraExpireDate < today) return '已过期' return '有效期至 ' + a.extraExpireDate }) +const tempCreditPeriodExpired = computed(() => tempCreditPeriodText.value === '已过期') + +/** 信用额度有效期文案 */ +const creditPeriodText = computed(() => { + const a = currentAccount.value + const total = Number(a.totalCredit) || 0 + if (total <= 0) return '' + if (!a.creditExpireDate) return '长期有效' + const today = proxy.parseTime(new Date(), '{y}-{m}-{d}') + if (a.creditExpireDate < today) return '已过期' + return '有效期至 ' + a.creditExpireDate +}) +const creditPeriodExpired = computed(() => creditPeriodText.value === '已过期') /* ============ 额度调整弹窗 ============ */ const creditFormRef = ref(null) @@ -1095,12 +1141,22 @@ const creditDialog = reactive({ visible: false, form: {} }) -const creditRules = { +const creditRules = computed(() => ({ creditType: [{ required: true, message: '请选择额度类型', trigger: 'change' }], adjustMode: [{ required: true, message: '请选择调整方式', trigger: 'change' }], changeAmount: [{ required: true, message: '调整金额必须大于0', trigger: 'blur' }], - reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }] -} + reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }], + expireDate: [{ + validator: (rule, value, callback) => { + if (creditDialog.form.creditType === 'CREDIT' && !value) { + callback(new Error('请选择信用额度失效日期')) + } else { + callback() + } + }, + trigger: 'change' + }] +})) /** 禁止选择晚于今天的日期(不允许预约未来生效) */ function afterToday(date) { @@ -1176,11 +1232,13 @@ function submitAdjustCredit() { proxy.$modal.msgError('调整金额必须大于0') return } - if (f.creditType === 'TEMP') { - if (f.expireDate && f.effectiveDate && f.expireDate <= f.effectiveDate) { - proxy.$modal.msgError('失效日期必须晚于生效日期') - return - } + if (f.creditType === 'CREDIT' && !f.expireDate) { + proxy.$modal.msgError('请选择信用额度失效日期') + return + } + if (f.expireDate && f.effectiveDate && f.expireDate <= f.effectiveDate) { + proxy.$modal.msgError('失效日期必须晚于生效日期') + return } if (creditAfter.value < 0) { proxy.$modal.msgError('调整后额度不能为负数') @@ -1206,10 +1264,8 @@ function submitAdjustCredit() { proxy.$modal.msgError('缺少客户ID,请从客户列表进入客户余额页') return } - if (f.creditType === 'TEMP') { - payload.effectiveDate = f.effectiveDate || null - payload.expireDate = f.expireDate || null - } + payload.effectiveDate = f.effectiveDate || null + payload.expireDate = f.expireDate || null creditSubmitLoading.value = true applyCreditAdjust(payload).then(() => { proxy.$modal.msgSuccess('申请已提交,等待审批') @@ -1561,13 +1617,76 @@ function afterApplyHandled() { getCreditFlowList() } +function isApplyNetIncrease(row) { + const a = currentAccount.value + const before = Number(row.creditType === 'TEMP' ? a.extraCredit : a.totalCredit) || 0 + const input = Number(row.changeAmount) || 0 + let after = before + if (row.adjustMode === 'SET') after = input + else if (row.adjustMode === 'ADD') after = before + input + else after = before - input + return after - before +} + +function approveApply(row, approveRoleId) { + return approveCreditApply(row.applyId, { approveRemark: '审批通过', approveRoleId: approveRoleId || null }) +} + function handleApproveApply(row) { - proxy.$modal.confirm(`确认通过额度调整申请「${row.applyNo}」?通过后将立即调整客户额度并写入额度流水。`) - .then(() => approveCreditApply(row.applyId, '审批通过')) - .then(() => { - proxy.$modal.msgSuccess('已审批通过') - afterApplyHandled() - }).catch(() => {}) + const netDelta = isApplyNetIncrease(row) + listMyCreditPools().then(res => { + const pools = res.data || [] + if (netDelta > 0 && pools.length > 0) { + // 非超管且名下有角色额度池:净增审批必须选池占用 + approveDialog.apply = row + approveDialog.pools = pools + approveDialog.roleId = pools.length === 1 ? pools[0].roleId : null + approveDialog.netDelta = netDelta + approveDialog.visible = true + return + } + const poolTip = (netDelta > 0 && pools.length === 0) + ? '(提示:净增额度需占用角色额度池,您名下未配置额度池,提交后将由后端校验拦截)' + : '' + proxy.$modal.confirm(`确认通过额度调整申请「${row.applyNo}」?通过后将立即调整客户额度并写入额度流水。${poolTip}`) + .then(() => approveApply(row, null)) + .then(() => { + proxy.$modal.msgSuccess('已审批通过') + afterApplyHandled() + }).catch(() => {}) + }).catch(() => {}) +} + +/** 审批选角色额度池弹窗 */ +const approveDialog = reactive({ + visible: false, + loading: false, + apply: null, + pools: [], + roleId: null, + netDelta: 0 +}) + +const selectedPool = computed(() => approveDialog.pools.find(p => p.roleId === approveDialog.roleId)) + +function submitApproveWithPool() { + if (!approveDialog.roleId) { + proxy.$modal.msgError('请选择本次额度分配占用的角色额度池') + return + } + const pool = selectedPool.value + if (pool && Number(pool.availableQuota) < Number(approveDialog.netDelta)) { + proxy.$modal.msgError(`角色【${pool.roleName}】额度池剩余 ${pool.availableQuota},本次需分配 ${formatMoney(approveDialog.netDelta)}`) + return + } + approveDialog.loading = true + approveApply(approveDialog.apply, approveDialog.roleId).then(() => { + proxy.$modal.msgSuccess('已审批通过') + approveDialog.visible = false + afterApplyHandled() + }).finally(() => { + approveDialog.loading = false + }) } function handleRejectApply(row) { diff --git a/bms-front/src/views/system/role/index.vue b/bms-front/src/views/system/role/index.vue index d83c5e6a..7b777fa2 100644 --- a/bms-front/src/views/system/role/index.vue +++ b/bms-front/src/views/system/role/index.vue @@ -71,6 +71,9 @@ + + + @@ -173,11 +176,50 @@ + + + + + + + + + CNY + + + {{ quotaForm.usedQuota }} CNY + + + {{ quotaForm.availableQuota }} CNY + + + + + + + +