This commit is contained in:
wuweihua
2026-09-17 10:00:13 +08:00
48 changed files with 1871 additions and 1815 deletions
@@ -10,8 +10,8 @@ import lombok.EqualsAndHashCode;
/**
* 应收账单余额核销 ar_balance_writeoff
*
* <p>用客户账户余额核销应收账单,与 ar_receipt_writeoff(收款核销)并列,
* 两者共享 ar_bill.written_off_amount / unwritten_amount 缓存。</p>
* <p>用客户账户余额核销应收账单,和客户账户余额流水联动,
* 共享 ar_bill.written_off_amount / unwritten_amount 缓存。</p>
*
* @author ruoyi
* @date 2026-09-16
@@ -1,33 +0,0 @@
package com.ruoyi.ar.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;
/** 应收真实收款。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class ArReceipt extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long receiptId;
private String receiptNo;
private Long customerId;
/** 客户名称,仅用于列表展示。 */
private String customerName;
/** 客户编码,仅用于列表展示。 */
private String customerCode;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date receiptDate;
private String currency;
private BigDecimal receiptAmount;
private BigDecimal unallocatedAmount;
private String paymentMethod;
private String bankReference;
private String status;
private String remark;
}
@@ -1,35 +0,0 @@
package com.ruoyi.ar.domain;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
/** 收款与账单的核销关系。 */
@Data
public class ArReceiptWriteoff implements Serializable
{
private static final long serialVersionUID = 1L;
private Long writeoffId;
private Long receiptId;
private Long arBillId;
/** 关联账单号,仅用于核销记录展示。 */
private String billNo;
/** 关联收款单号,仅用于核销记录展示。 */
private String receiptNo;
private String writeoffNo;
private BigDecimal writeoffAmount;
private String currency;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date writeoffDate;
private String status;
private String remark;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String cancelBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date cancelTime;
}
@@ -1,16 +0,0 @@
package com.ruoyi.ar.domain.vo;
import java.util.Date;
import com.ruoyi.ar.domain.ArReceipt;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 收款查询参数。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class ArReceiptQuery extends ArReceipt
{
private static final long serialVersionUID = 1L;
private Date receiptDateStart;
private Date receiptDateEnd;
}
@@ -1,35 +0,0 @@
package com.ruoyi.ar.domain.vo;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 批量收款核销参数。 */
@Data
public class ArReceiptWriteoffRequest
{
@NotNull(message = "收款ID不能为空")
private Long receiptId;
@NotEmpty(message = "至少选择一张账单")
@Valid
private List<Allocation> allocations;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date writeoffDate;
private String currency;
private String remark;
private String requestId;
@Data
public static class Allocation
{
@NotNull(message = "账单ID不能为空")
private Long arBillId;
@NotNull(message = "核销金额不能为空")
private BigDecimal amount;
}
}
@@ -1,22 +0,0 @@
package com.ruoyi.ar.enums;
/** 应收收款状态。 */
public enum ArReceiptStatus
{
REGISTERED("REGISTERED"),
PARTIAL_WRITTEN_OFF("PARTIAL_WRITTEN_OFF"),
WRITTEN_OFF("WRITTEN_OFF"),
CANCELLED("CANCELLED");
private final String code;
ArReceiptStatus(String code)
{
this.code = code;
}
public String getCode()
{
return code;
}
}
@@ -1,20 +0,0 @@
package com.ruoyi.ar.enums;
/** 收款核销状态。 */
public enum ArWriteoffStatus
{
EFFECTIVE("EFFECTIVE"),
CANCELLED("CANCELLED");
private final String code;
ArWriteoffStatus(String code)
{
this.code = code;
}
public String getCode()
{
return code;
}
}
@@ -1,30 +0,0 @@
package com.ruoyi.ar.mapper;
import java.math.BigDecimal;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArReceipt;
import com.ruoyi.ar.domain.vo.ArReceiptQuery;
public interface ArReceiptMapper
{
List<ArReceipt> selectArReceiptList(ArReceiptQuery query);
ArReceipt selectArReceiptById(Long receiptId);
ArReceipt selectArReceiptByIdForUpdate(Long receiptId);
ArReceipt selectArReceiptByNo(String receiptNo);
ArReceipt selectArReceiptByReference(@Param("paymentMethod") String paymentMethod,
@Param("bankReference") String bankReference);
int insertArReceipt(ArReceipt receipt);
int updateBalanceAndStatus(@Param("receiptId") Long receiptId,
@Param("unallocatedAmount") BigDecimal unallocatedAmount,
@Param("status") String status, @Param("operator") String operator);
int updateCancelled(@Param("receiptId") Long receiptId, @Param("operator") String operator,
@Param("remark") String remark);
}
@@ -1,26 +0,0 @@
package com.ruoyi.ar.mapper;
import java.math.BigDecimal;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArReceiptWriteoff;
public interface ArReceiptWriteoffMapper
{
List<ArReceiptWriteoff> selectByBillId(Long arBillId);
List<ArReceiptWriteoff> selectByReceiptId(Long receiptId);
ArReceiptWriteoff selectById(Long writeoffId);
ArReceiptWriteoff selectByIdForUpdate(Long writeoffId);
int insert(ArReceiptWriteoff writeoff);
int updateCancelled(@Param("writeoffId") Long writeoffId, @Param("cancelBy") String cancelBy,
@Param("remark") String remark);
BigDecimal sumEffectiveByBillId(Long arBillId);
BigDecimal sumEffectiveByReceiptId(Long receiptId);
}
@@ -7,8 +7,7 @@ import com.ruoyi.ar.domain.ArBill;
/**
* 应收账单余额核销 Service接口
*
* <p>资金来源为客户账户余额(customer_account.balance),与收款核销
* (ar_receipt_writeoff)并列;核销时三方联动:账户余额扣减、已用额度释放、
* <p>资金来源为客户账户余额(customer_account.balance);核销时三方联动:账户余额扣减、已用额度释放、
* 账单已核销/未核销金额及状态更新。</p>
*
* @author ruoyi
@@ -1,22 +0,0 @@
package com.ruoyi.ar.service;
import java.util.List;
import com.ruoyi.ar.domain.ArReceipt;
import com.ruoyi.ar.domain.ArReceiptWriteoff;
import com.ruoyi.ar.domain.vo.ArReceiptQuery;
import com.ruoyi.ar.domain.vo.ArReceiptWriteoffRequest;
public interface IArReceiptService
{
List<ArReceipt> selectReceiptList(ArReceiptQuery query);
List<ArReceiptWriteoff> selectWriteoffs(Long receiptId);
ArReceipt registerReceipt(ArReceipt receipt, String operator, String requestId);
void cancelReceipt(Long receiptId, String remark, String operator, String requestId);
List<ArReceiptWriteoff> writeoff(ArReceiptWriteoffRequest request, String operator, String requestId);
void cancelWriteoff(Long writeoffId, String remark, String operator, String requestId);
}
@@ -23,8 +23,10 @@ import com.ruoyi.ar.service.IArBillLogService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.customer.customermoney.api.BalanceChangeCommand;
import com.ruoyi.customer.customermoney.api.CustomerFundBizType;
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.service.ICustomerMoneyService;
/**
* 应收账单余额核销 Service实现
@@ -48,7 +50,7 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
private IArBillLogService logService;
@Autowired
private ICustomerMoneyService customerMoneyService;
private ICustomerAccountApi customerAccountApi;
@Override
public List<ArBill> selectUnsettledBills(Long customerId)
@@ -125,8 +127,15 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
// 3. 扣减账户余额并释放等额已用额度,写 BILL_PAY 扣款流水(余额不足在此处拦截)
String flowRemark = "余额核销账单:" + bill.getBillNo()
+ (StringUtils.hasText(writeoff.getRemark()) ? "(" + writeoff.getRemark() + ")" : "");
CustomerBalanceFlow flow = customerMoneyService.payBill(bill.getCustomerId(), currency,
amount, writeoff.getWriteoffId(), bill.getBillNo(), flowRemark);
BalanceChangeCommand payCmd = new BalanceChangeCommand();
payCmd.setCustomerId(bill.getCustomerId());
payCmd.setCurrency(currency);
payCmd.setAmount(amount);
payCmd.setBizType(CustomerFundBizType.BILL_PAY);
payCmd.setBizId(writeoff.getWriteoffId());
payCmd.setBizNo(bill.getBillNo());
payCmd.setRemark(flowRemark);
CustomerBalanceFlow flow = customerAccountApi.deductBalance(payCmd);
writeoffMapper.updateFlowRefs(writeoff.getWriteoffId(), flow.getAccountId(), flow.getId());
// 4. 更新账单已核销/未核销缓存与状态(乐观条件更新,状态被并发改动则整体回滚)
@@ -178,8 +187,16 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
// 2. 余额回补、已用额度重新占用,写反向 BILL_PAY 流水
String flowRemark = "反核销回补账单:" + bill.getBillNo() + "(" + cancelRemark + ")";
CustomerBalanceFlow reverseFlow = customerMoneyService.reverseBillPay(writeoff.getCustomerId(),
writeoff.getCurrency(), nvl(writeoff.getWriteoffAmount()), writeoffId, bill.getBillNo(), flowRemark);
BalanceChangeCommand reverseCmd = new BalanceChangeCommand();
reverseCmd.setCustomerId(writeoff.getCustomerId());
reverseCmd.setCurrency(writeoff.getCurrency());
reverseCmd.setAmount(nvl(writeoff.getWriteoffAmount()));
reverseCmd.setBizType(CustomerFundBizType.BILL_PAY);
reverseCmd.setBizId(writeoffId);
reverseCmd.setBizNo(bill.getBillNo());
reverseCmd.setRemark(flowRemark);
reverseCmd.setReoccupyUsedCredit(Boolean.TRUE);
CustomerBalanceFlow reverseFlow = customerAccountApi.refundBalance(reverseCmd);
// 3. 账单核销金额回退,状态按剩余已核销额重算(归零则回到已确认)
BigDecimal written = nvl(bill.getWrittenOffAmount()).subtract(nvl(writeoff.getWriteoffAmount()));
@@ -58,7 +58,9 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.customer.customermoney.service.ICustomerMoneyService;
import com.ruoyi.customer.customermoney.api.CreditUsageCommand;
import com.ruoyi.customer.customermoney.api.CustomerFundBizType;
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
import com.ruoyi.price.domain.PrcIncomeBillFee;
import com.ruoyi.price.domain.PrcOrderBill;
import com.ruoyi.price.mapper.PrcIncomeBillFeeMapper;
@@ -81,7 +83,7 @@ public class ArBillServiceImpl implements IArBillService
@Autowired private IArBillLogService logService;
@Autowired private PrcOrderBillMapper sourceBillMapper;
@Autowired private PrcIncomeBillFeeMapper sourceFeeMapper;
@Autowired private ICustomerMoneyService customerMoneyService;
@Autowired private ICustomerAccountApi customerAccountApi;
@Override
public List<ArBill> selectArBillList(ArBillQuery query)
@@ -279,9 +281,9 @@ public class ArBillServiceImpl implements IArBillService
throw failure(arBillId, "BILL", String.valueOf(arBillId), "CONFIRM", bill.getStatus(),
"账单状态已发生变化,请刷新后重试", operator, requestId);
}
// 账单确认:应收形成,按账单金额占用客户授信额度(账户不存在时仅CNY会自动初始化)
customerMoneyService.occupyCredit(bill.getCustomerId(), bill.getCurrency(),
nvl(bill.getTotalAmount()));
// 账单确认:应收形成,按账单金额占用客户授信额度(账户不存在时仅CNY会自动初始化)。
// 不校验可用额度,兼容历史 used_credit 可高于总额度、外币账户静默跳过的口径
customerAccountApi.occupyCredit(buildCreditCommand(bill));
recordSuccess(bill, "CONFIRM", "确认应收账单", ArBillStatus.DRAFT.getCode(), ArBillStatus.CONFIRMED.getCode(), requestId, operator);
return selectArBillById(arBillId);
}
@@ -322,8 +324,7 @@ public class ArBillServiceImpl implements IArBillService
// 账单作废:已确认账单占用的授信额度全额释放(可作废的CONFIRMED账单必然零核销)
if (currentStatus == ArBillStatus.CONFIRMED)
{
customerMoneyService.releaseCredit(bill.getCustomerId(), bill.getCurrency(),
nvl(bill.getTotalAmount()));
customerAccountApi.releaseCredit(buildCreditCommand(bill));
}
if (!sourceBillIds.isEmpty())
{
@@ -860,6 +861,21 @@ public class ArBillServiceImpl implements IArBillService
private BigDecimal decimal(BigDecimal value, int scale) { return value == null ? null : value.setScale(scale, java.math.RoundingMode.HALF_UP); }
private BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
/** 构造账单授信占用/释放命令(确认占用、作废释放共用,关联账单单据便于流水追溯) */
private CreditUsageCommand buildCreditCommand(ArBill bill)
{
CreditUsageCommand command = new CreditUsageCommand();
command.setCustomerId(bill.getCustomerId());
command.setCurrency(bill.getCurrency());
command.setAmount(nvl(bill.getTotalAmount()));
command.setBizType(CustomerFundBizType.AR_BILL);
command.setBizId(bill.getArBillId());
command.setBizNo(bill.getBillNo());
// 兼容历史口径:不校验可用额度(允许 used_credit 高于总额度、外币账户静默跳过)
command.setCheckAvailable(Boolean.FALSE);
return command;
}
private String safeFilePart(String value) { return StringUtils.isEmpty(value) ? "客户" : value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_"); }
private String maskAccount(String value) { if (StringUtils.isEmpty(value)) return ""; return value.length() <= 4 ? "****" : "****" + value.substring(value.length() - 4); }
private String sha256(byte[] data) throws NoSuchAlgorithmException { byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); StringBuilder sb = new StringBuilder(); for (byte b : digest) sb.append(String.format("%02x", b)); return sb.toString(); }
@@ -1,275 +0,0 @@
package com.ruoyi.ar.service.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.ar.domain.ArBill;
import com.ruoyi.ar.domain.ArBillLog;
import com.ruoyi.ar.domain.ArReceipt;
import com.ruoyi.ar.domain.ArReceiptWriteoff;
import com.ruoyi.ar.domain.vo.ArReceiptQuery;
import com.ruoyi.ar.domain.vo.ArReceiptWriteoffRequest;
import com.ruoyi.ar.enums.ArBillStatus;
import com.ruoyi.ar.enums.ArLogResult;
import com.ruoyi.ar.enums.ArReceiptStatus;
import com.ruoyi.ar.enums.ArWriteoffStatus;
import com.ruoyi.ar.mapper.ArBillMapper;
import com.ruoyi.ar.mapper.ArBillLogMapper;
import com.ruoyi.ar.mapper.ArReceiptMapper;
import com.ruoyi.ar.mapper.ArReceiptWriteoffMapper;
import com.ruoyi.ar.service.IArBillLogService;
import com.ruoyi.ar.service.IArReceiptService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
@Service
public class ArReceiptServiceImpl implements IArReceiptService
{
@Autowired private ArReceiptMapper receiptMapper;
@Autowired private ArReceiptWriteoffMapper writeoffMapper;
@Autowired private ArBillMapper billMapper;
@Autowired private ArBillLogMapper logMapper;
@Autowired private IArBillLogService logService;
@Override
public List<ArReceipt> selectReceiptList(ArReceiptQuery query)
{
return receiptMapper.selectArReceiptList(query);
}
@Override
public List<ArReceiptWriteoff> selectWriteoffs(Long receiptId)
{
return writeoffMapper.selectByReceiptId(receiptId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public ArReceipt registerReceipt(ArReceipt receipt, String operator, String requestId)
{
if (receipt == null || receipt.getCustomerId() == null || receipt.getReceiptDate() == null
|| receipt.getReceiptAmount() == null || receipt.getReceiptAmount().compareTo(BigDecimal.ZERO) <= 0)
{
throw failure(null, "RECEIPT", "REGISTER", "收款客户、日期和正数金额不能为空", operator, requestId);
}
if (StringUtils.isEmpty(receipt.getReceiptNo()))
{
receipt.setReceiptNo("RC" + DateUtils.dateTimeNow("yyyyMMddHHmmss") + IdUtils.fastSimpleUUID().substring(0, 6).toUpperCase());
}
if (StringUtils.isEmpty(receipt.getCurrency())) receipt.setCurrency("CNY");
if (StringUtils.isEmpty(receipt.getPaymentMethod())) receipt.setPaymentMethod("BANK");
ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, "RECEIPT");
if (previous != null && isNumber(previous.getObjectId()))
{
ArReceipt existed = receiptMapper.selectArReceiptById(Long.valueOf(previous.getObjectId()));
if (existed != null) return existed;
}
if (receiptMapper.selectArReceiptByNo(receipt.getReceiptNo()) != null)
{
throw failure(null, "RECEIPT", receipt.getReceiptNo(), "收款单号已存在", operator, requestId);
}
if (!StringUtils.isEmpty(receipt.getBankReference())
&& receiptMapper.selectArReceiptByReference(receipt.getPaymentMethod(), receipt.getBankReference()) != null)
{
throw failure(null, "RECEIPT", receipt.getReceiptNo(), "银行流水号已存在", operator, requestId);
}
receipt.setUnallocatedAmount(receipt.getReceiptAmount());
receipt.setStatus(ArReceiptStatus.REGISTERED.getCode());
receipt.setCreateBy(operator);
receipt.setUpdateBy(operator);
try
{
receiptMapper.insertArReceipt(receipt);
}
catch (DuplicateKeyException e)
{
ArReceipt existed = receiptMapper.selectArReceiptByNo(receipt.getReceiptNo());
if (existed != null) return existed;
throw e;
}
ArBillLog log = new ArBillLog();
log.setObjectType("RECEIPT"); log.setObjectId(String.valueOf(receipt.getReceiptId()));
log.setOperationType("RECEIPT"); log.setOperationDesc("登记应收收款");
log.setOperatorId(operator); log.setOperatorName(operator); log.setOperationSource("WEB");
log.setRequestId(requestId); log.setResult(ArLogResult.SUCCESS.getCode()); logService.record(log);
return receipt;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelReceipt(Long receiptId, String remark, String operator, String requestId)
{
ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, "RECEIPT");
if (previous != null && "CANCEL_RECEIPT".equals(previous.getOperationType())) return;
ArReceipt receipt = receiptMapper.selectArReceiptByIdForUpdate(receiptId);
if (receipt == null) throw failure(null, "RECEIPT", String.valueOf(receiptId), "收款不存在", operator, requestId);
if (!ArReceiptStatus.REGISTERED.getCode().equals(receipt.getStatus())
|| nvl(receipt.getUnallocatedAmount()).compareTo(nvl(receipt.getReceiptAmount())) != 0)
{
throw failure(null, "RECEIPT", String.valueOf(receiptId), "收款已发生核销,不能直接取消", operator, requestId);
}
receiptMapper.updateCancelled(receiptId, operator, StringUtils.isEmpty(remark) ? "取消收款" : remark);
ArBillLog log = new ArBillLog();
log.setObjectType("RECEIPT"); log.setObjectId(String.valueOf(receiptId)); log.setOperationType("CANCEL_RECEIPT");
log.setOperationDesc(StringUtils.isEmpty(remark) ? "取消收款" : remark); log.setFromStatus(ArReceiptStatus.REGISTERED.getCode());
log.setToStatus(ArReceiptStatus.CANCELLED.getCode()); log.setOperatorId(operator); log.setOperatorName(operator);
log.setOperationSource("WEB"); log.setRequestId(requestId); log.setResult(ArLogResult.SUCCESS.getCode()); logService.record(log);
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<ArReceiptWriteoff> writeoff(ArReceiptWriteoffRequest request, String operator, String requestId)
{
if (request == null || request.getReceiptId() == null || request.getAllocations() == null || request.getAllocations().isEmpty())
{
throw failure(null, "WRITEOFF", "", "核销收款和账单不能为空", operator, requestId);
}
ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, "WRITEOFF");
if (previous != null && isNumber(previous.getObjectId()))
{
ArReceiptWriteoff existed = writeoffMapper.selectById(Long.valueOf(previous.getObjectId()));
if (existed != null) return writeoffMapper.selectByReceiptId(existed.getReceiptId());
}
ArReceipt receipt = receiptMapper.selectArReceiptByIdForUpdate(request.getReceiptId());
if (receipt == null) throw failure(null, "WRITEOFF", String.valueOf(request.getReceiptId()), "收款不存在", operator, requestId);
if (ArReceiptStatus.CANCELLED.getCode().equals(receipt.getStatus()))
{
throw failure(null, "WRITEOFF", String.valueOf(request.getReceiptId()), "收款已取消,不允许核销", operator, requestId);
}
String currency = StringUtils.isEmpty(request.getCurrency()) ? receipt.getCurrency() : request.getCurrency();
if (!currency.equalsIgnoreCase(receipt.getCurrency()))
{
throw failure(null, "WRITEOFF", String.valueOf(request.getReceiptId()), "收款与核销币种不一致", operator, requestId);
}
BigDecimal allocationTotal = BigDecimal.ZERO;
for (ArReceiptWriteoffRequest.Allocation allocation : request.getAllocations())
{
if (allocation.getAmount() == null || allocation.getAmount().compareTo(BigDecimal.ZERO) <= 0)
{
throw failure(null, "WRITEOFF", String.valueOf(request.getReceiptId()), "核销金额必须大于零", operator, requestId);
}
allocationTotal = allocationTotal.add(allocation.getAmount());
}
if (allocationTotal.compareTo(nvl(receipt.getUnallocatedAmount())) > 0)
{
throw failure(null, "WRITEOFF", String.valueOf(request.getReceiptId()), "核销金额超过收款未分配余额", operator, requestId);
}
List<ArReceiptWriteoff> result = new ArrayList<>();
BigDecimal remainingReceipt = nvl(receipt.getUnallocatedAmount());
Date writeoffDate = request.getWriteoffDate() == null ? new Date() : request.getWriteoffDate();
for (ArReceiptWriteoffRequest.Allocation allocation : request.getAllocations())
{
ArBill bill = billMapper.selectArBillByIdForUpdate(allocation.getArBillId());
if (bill == null) throw failure(null, "WRITEOFF", String.valueOf(allocation.getArBillId()), "账单不存在", operator, requestId);
ArBillStatus billStatus = ArBillStatus.of(bill.getStatus());
if (billStatus == ArBillStatus.VOID) throw failure(bill.getArBillId(), "WRITEOFF", String.valueOf(bill.getArBillId()), "账单已作废,不允许核销", operator, requestId);
if (billStatus == null || !billStatus.canWriteOff())
{
throw failure(bill.getArBillId(), "WRITEOFF", String.valueOf(bill.getArBillId()), "账单尚未确认,不允许核销", operator, requestId);
}
if (!currency.equalsIgnoreCase(bill.getCurrency())) throw failure(bill.getArBillId(), "WRITEOFF", String.valueOf(bill.getArBillId()), "收款与账单币种不一致", operator, requestId);
BigDecimal amount = allocation.getAmount();
if (amount.compareTo(nvl(bill.getUnwrittenAmount())) > 0)
{
throw failure(bill.getArBillId(), "WRITEOFF", String.valueOf(bill.getArBillId()), "核销金额超过账单未核销余额", operator, requestId);
}
ArReceiptWriteoff writeoff = new ArReceiptWriteoff();
writeoff.setReceiptId(receipt.getReceiptId()); writeoff.setArBillId(bill.getArBillId());
writeoff.setWriteoffNo("WO" + DateUtils.dateTimeNow("yyyyMMddHHmmss") + IdUtils.fastSimpleUUID().substring(0, 6).toUpperCase());
writeoff.setWriteoffAmount(amount); writeoff.setCurrency(currency); writeoff.setWriteoffDate(writeoffDate);
writeoff.setStatus(ArWriteoffStatus.EFFECTIVE.getCode()); writeoff.setRemark(request.getRemark()); writeoff.setCreateBy(operator);
writeoffMapper.insert(writeoff);
remainingReceipt = remainingReceipt.subtract(amount);
BigDecimal written = nvl(bill.getWrittenOffAmount()).add(amount);
BigDecimal unwritten = nvl(bill.getUnwrittenAmount()).subtract(amount);
String nextBillStatus = billStatus(written, nvl(bill.getTotalAmount()), bill.getStatus());
if (billMapper.updateArBillAmounts(bill.getArBillId(), written, unwritten,
nextBillStatus, operator, bill.getStatus()) != 1)
{
throw failure(bill.getArBillId(), "WRITEOFF", String.valueOf(bill.getArBillId()), "账单状态已发生变化,请刷新后重试", operator, requestId);
}
writeoffLog(writeoff, bill, operator, requestId);
result.add(writeoff);
}
receiptMapper.updateBalanceAndStatus(receipt.getReceiptId(), remainingReceipt,
remainingReceipt.compareTo(BigDecimal.ZERO) == 0 ? ArReceiptStatus.WRITTEN_OFF.getCode() : ArReceiptStatus.PARTIAL_WRITTEN_OFF.getCode(), operator);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelWriteoff(Long writeoffId, String remark, String operator, String requestId)
{
ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, "WRITEOFF");
if (previous != null && "CANCEL_WRITEOFF".equals(previous.getOperationType())) return;
ArReceiptWriteoff writeoff = writeoffMapper.selectByIdForUpdate(writeoffId);
if (writeoff == null) throw failure(null, "WRITEOFF", String.valueOf(writeoffId), "核销记录不存在", operator, requestId);
if (!ArWriteoffStatus.EFFECTIVE.getCode().equals(writeoff.getStatus())) throw failure(writeoff.getArBillId(), "WRITEOFF", String.valueOf(writeoffId), "只有有效核销可以取消", operator, requestId);
ArReceipt receipt = receiptMapper.selectArReceiptByIdForUpdate(writeoff.getReceiptId());
ArBill bill = billMapper.selectArBillByIdForUpdate(writeoff.getArBillId());
if (receipt == null || bill == null) throw failure(writeoff.getArBillId(), "WRITEOFF", String.valueOf(writeoffId), "核销关联的收款或账单不存在", operator, requestId);
writeoffMapper.updateCancelled(writeoffId, operator, StringUtils.isEmpty(remark) ? "取消核销" : remark);
BigDecimal receiptUnallocated = nvl(receipt.getUnallocatedAmount()).add(writeoff.getWriteoffAmount());
receiptMapper.updateBalanceAndStatus(receipt.getReceiptId(), receiptUnallocated,
receiptUnallocated.compareTo(nvl(receipt.getReceiptAmount())) == 0 ? ArReceiptStatus.REGISTERED.getCode() : ArReceiptStatus.PARTIAL_WRITTEN_OFF.getCode(), operator);
BigDecimal written = nvl(bill.getWrittenOffAmount()).subtract(writeoff.getWriteoffAmount());
if (written.compareTo(BigDecimal.ZERO) < 0) written = BigDecimal.ZERO;
BigDecimal unwritten = nvl(bill.getTotalAmount()).subtract(written);
String nextBillStatus = billStatus(written, nvl(bill.getTotalAmount()), bill.getStatus());
if (billMapper.updateArBillAmounts(bill.getArBillId(), written, unwritten,
nextBillStatus, operator, bill.getStatus()) != 1)
{
throw failure(bill.getArBillId(), "WRITEOFF", String.valueOf(bill.getArBillId()), "账单状态已发生变化,请刷新后重试", operator, requestId);
}
ArBillLog log = new ArBillLog();
log.setArBillId(bill.getArBillId()); log.setObjectType("WRITEOFF"); log.setObjectId(String.valueOf(writeoffId));
log.setOperationType("CANCEL_WRITEOFF"); log.setOperationDesc(StringUtils.isEmpty(remark) ? "取消核销" : remark);
log.setFromStatus(ArWriteoffStatus.EFFECTIVE.getCode()); log.setToStatus(ArWriteoffStatus.CANCELLED.getCode()); log.setOperatorId(operator); log.setOperatorName(operator);
log.setOperationSource("WEB"); log.setRequestId(requestId); log.setResult(ArLogResult.SUCCESS.getCode()); logService.record(log);
}
private void writeoffLog(ArReceiptWriteoff writeoff, ArBill bill, String operator, String requestId)
{
ArBillLog log = new ArBillLog();
log.setArBillId(bill.getArBillId()); log.setObjectType("WRITEOFF"); log.setObjectId(String.valueOf(writeoff.getWriteoffId()));
log.setOperationType("WRITEOFF"); log.setOperationDesc("收款核销到账单"); log.setFromStatus(bill.getStatus());
log.setToStatus(billStatus(nvl(bill.getWrittenOffAmount()).add(writeoff.getWriteoffAmount()), nvl(bill.getTotalAmount()), bill.getStatus()));
log.setOperatorId(operator); log.setOperatorName(operator); log.setOperationSource("WEB"); log.setRequestId(requestId); log.setResult(ArLogResult.SUCCESS.getCode());
logService.record(log);
}
private String billStatus(BigDecimal written, BigDecimal total, String current)
{
if (written.compareTo(BigDecimal.ZERO) == 0)
{
return ArBillStatus.DRAFT.getCode().equals(current)
? ArBillStatus.DRAFT.getCode() : ArBillStatus.CONFIRMED.getCode();
}
return written.compareTo(total) >= 0
? ArBillStatus.WRITTEN_OFF.getCode() : ArBillStatus.PARTIAL_WRITTEN_OFF.getCode();
}
private ServiceException failure(Long billId, String objectType, String message, String operator, String requestId)
{
logService.recordFailure(billId, objectType, objectType, "RECEIPT", null, message, operator, requestId);
return new ServiceException(message);
}
private ServiceException failure(Long billId, String objectType, String objectId, String message, String operator, String requestId)
{
logService.recordFailure(billId, objectType, objectId,
"RECEIPT".equals(objectType) ? "RECEIPT" : "WRITEOFF", null, message, operator, requestId);
return new ServiceException(message);
}
private BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private boolean isNumber(String value) { if (StringUtils.isEmpty(value)) return false; try { Long.valueOf(value); return true; } catch (NumberFormatException e) { return false; } }
}
@@ -0,0 +1,63 @@
package com.ruoyi.customer.customermoney.api;
import java.math.BigDecimal;
import lombok.Data;
/**
* 客户账户余额变动命令(扣减/回补余额,写余额流水;可选联动已用额度)。
*
* <p>典型场景:</p>
* <ul>
* <li>余额核销账单/订单扣款 -> {@link ICustomerAccountApi#deductBalance}(余额减少,默认同步释放已用额度)</li>
* <li>收款审核入账/反核销回补/退款 -> {@link ICustomerAccountApi#refundBalance}(余额增加)</li>
* </ul>
*
* @author ruoyi
* @date 2026-09-17
*/
@Data
public class BalanceChangeCommand
{
/** 客户ID(必填) */
private Long customerId;
/** 币种(流水原币口径;为空按本位币 CNY 处理) */
private String currency;
/**
* 实际加锁/变动的账户币种(为空时取 {@link #currency})。
* 外币收款折算入账场景:账户只有CNY,需锁CNY账户按 {@link #baseAmount} 变动余额,
* 但流水币种仍保留收款原币 {@link #currency}。
*/
private String accountCurrency;
/** 变动金额,原币口径(必须大于0,恒为正数) */
private BigDecimal amount;
/** 对CNY汇率(1原币=?CNY),为空按1处理 */
private BigDecimal exchangeRate;
/** 本位币变动金额(实际参与余额变动,为空时取 {@link #amount}) */
private BigDecimal baseAmount;
/** 业务类型,取 {@link CustomerFundBizType} 常量(必填) */
private String bizType;
/** 关联业务单据ID */
private Long bizId;
/** 关联业务单据号 */
private String bizNo;
/** 流水备注 */
private String remark;
/** 扣减余额时是否同步释放等额已用额度(账单核销场景为true),默认true */
private Boolean releaseUsedCredit = Boolean.TRUE;
/** 回补余额时是否同步重新占用等额已用额度(反核销场景为true,收款入账为false),默认false */
private Boolean reoccupyUsedCredit = Boolean.FALSE;
/** 扣减时是否校验余额充足(不足抛异常),默认true */
private Boolean checkBalanceEnough = Boolean.TRUE;
}
@@ -0,0 +1,49 @@
package com.ruoyi.customer.customermoney.api;
import java.math.BigDecimal;
import lombok.Data;
/**
* 授信额度占用/释放命令(变动客户账户已用额度 used_credit,并写额度流水)。
*
* <p>典型场景:</p>
* <ul>
* <li>计费订单开单/应收账单确认 -> {@link ICustomerAccountApi#occupyCredit}(已用额度增加)</li>
* <li>账单作废/计费变动核减 -> {@link ICustomerAccountApi#releaseCredit}(已用额度减少)</li>
* </ul>
*
* @author ruoyi
* @date 2026-09-17
*/
@Data
public class CreditUsageCommand
{
/** 客户ID(必填) */
private Long customerId;
/** 币种(为空按本位币 CNY 处理) */
private String currency;
/** 变动金额(必须大于0,恒为正数) */
private BigDecimal amount;
/** 业务来源类型,取 {@link CustomerFundBizType} 常量(必填) */
private String bizType;
/** 关联业务单据ID(如账单ID/订单ID) */
private Long bizId;
/** 关联业务单据号(如账单号/订单号,写入流水便于追溯) */
private String bizNo;
/** 流水原因(为空按业务类型+单据号自动生成) */
private String reason;
/**
* 占用时是否校验可用额度(授信总额-已用 >= 本次占用额)。
* true=余额/额度不足直接抛异常(计费开单等新业务场景默认值);
* false=允许超出总额度占用(兼容历史账单确认口径)。
* 释放场景该参数无意义。
*/
private Boolean checkAvailable = Boolean.TRUE;
}
@@ -0,0 +1,27 @@
package com.ruoyi.customer.customermoney.api;
/**
* 客户资金变动业务来源类型(写入余额流水 biz_type / 额度流水 biz_type)。
* 各业务模块调用 {@link ICustomerAccountApi} 时传本类常量,便于按来源单据反查流水。
*
* @author ruoyi
* @date 2026-09-17
*/
public final class CustomerFundBizType
{
/** 收款单(余额入账) */
public static final String RECEIPT = "RECEIPT";
/** 应收账单(确认占用授信 / 作废释放授信) */
public static final String AR_BILL = "AR_BILL";
/** 账单余额核销(余额扣款 / 反核销回补) */
public static final String BILL_PAY = "BILL_PAY";
/** 计费订单(开单占用授信 / 计费变动核减释放) */
public static final String ORDER = "ORDER";
private CustomerFundBizType()
{
}
}
@@ -0,0 +1,84 @@
package com.ruoyi.customer.customermoney.api;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
/**
* 客户资金公共服务:客户余额扣减/回补、授信额度占用/释放的统一入口。
*
* <p>客户余额、授信额度及其流水(客户模块资产)的所有变动都必须经过本接口,
* 其他模块(应收账单、计费订单/计费变动等)不得直接更新 customer_account /
* 直接插入流水表。每个方法在同一事务内完成:行锁账户 -> 乐观锁变动 -> 写流水,
* 调用方自身的事务会与本操作共享事务(默认 REQUIRED),任一失败整体回滚。</p>
*
* <ul>
* <li>单据形成应收/计费开单:{@link #occupyCredit} 占用授信(used_credit 增加)</li>
* <li>单据作废/计费核减:{@link #releaseCredit} 释放授信(used_credit 减少)</li>
* <li>用账户余额支付/核销:{@link #deductBalance} 扣余额(可同步释放已用额度)</li>
* <li>收款入账/退款/反核销回补:{@link #refundBalance} 加余额(可同步重新占用)</li>
* </ul>
*
* @author ruoyi
* @date 2026-09-17
*/
public interface ICustomerAccountApi
{
/**
* 普通查询客户某币种账户(不加锁,不存在返回null),供业务模块判断余额/可用额度。
*/
CustomerAccount getAccount(Long customerId, String currency);
/**
* 事务内行锁获取客户CNY账户,不存在则按客户档案额度自动初始化(不处理临时额度过期)。
* 调用方拿到后如需最新临时额度,可再调 {@link #expireTempCreditIfNeeded}。
*/
CustomerAccount lockCnyAccount(Long customerId);
/**
* 临时额度到期自动失效(额度清零 + 回写客户档案 + EXPIRE 额度流水)。
* 入参必须是同一事务内行锁查出的账户;未过期时不做任何操作。
*/
void expireTempCreditIfNeeded(CustomerAccount account);
/**
* 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY,档案有值按档案),
* 写 INIT 额度流水;CNY账户已存在时不重复创建。
*/
int initDefaultCnyAccount(Long customerId);
/**
* 占用授信额度:used_credit += amount,写 OCCUPY 额度流水(关联来源单据)。
* checkAvailable=true 时可用额度不足抛 ServiceException;CNY账户不存在自动初始化,
* 外币账户不存在时静默跳过(返回null,兼容历史口径)。
*
* @return 已落库的额度流水(含ID);账户不存在跳过场景返回null
*/
CustomerCreditFlow occupyCredit(CreditUsageCommand command);
/**
* 释放授信额度:used_credit -= amount(不小于0),写 RELEASE 额度流水。
* 账户不存在时静默跳过(返回null)。
*
* @return 已落库的额度流水(含ID);账户不存在跳过场景返回null
*/
CustomerCreditFlow releaseCredit(CreditUsageCommand command);
/**
* 扣减账户余额:balance -= amount,写 -1 余额流水;
* releaseUsedCredit=true 时同步释放等额已用额度(余额清偿应收场景)。
* 账户不存在或余额不足(checkBalanceEnough=true)抛 ServiceException。
*
* @return 已落库的余额流水(含ID、账户ID)
*/
CustomerBalanceFlow deductBalance(BalanceChangeCommand command);
/**
* 回补/入账账户余额:balance += amount,写 +1 余额流水;
* reoccupyUsedCredit=true 时同步加回等额已用额度(反核销场景),
* 收款审核入账等场景保持false。
*
* @return 已落库的余额流水(含ID、账户ID)
*/
CustomerBalanceFlow refundBalance(BalanceChangeCommand command);
}
@@ -0,0 +1,566 @@
package com.ruoyi.customer.customermoney.api.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.customer.customerinfo.domain.BmsCustomer;
import com.ruoyi.customer.customerinfo.mapper.BmsCustomerMapper;
import com.ruoyi.customer.customermoney.api.BalanceChangeCommand;
import com.ruoyi.customer.customermoney.api.CreditUsageCommand;
import com.ruoyi.customer.customermoney.api.CustomerFundBizType;
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow;
import com.ruoyi.customer.customermoney.mapper.CustomerMoneyMapper;
import com.ruoyi.system.domain.BmsExchangeRate;
/**
* 客户资金公共服务实现:客户账户余额/授信额度变动与流水落库的唯一收口。
* 加锁顺序统一为「业务单据(调用方持锁)-> 客户账户(本服务行锁)」。
*
* @author ruoyi
* @date 2026-09-17
*/
@Service
public class CustomerAccountApiImpl implements ICustomerAccountApi
{
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
/** 本位币币种 */
private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY;
/** 新客户默认总额度(CNY) */
private static final BigDecimal DEFAULT_CREDIT = new BigDecimal("1000");
/** 金额保留位数 */
private static final int MONEY_SCALE = 2;
@Autowired
private CustomerMoneyMapper customerMoneyMapper;
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
@Override
public CustomerAccount getAccount(Long customerId, String currency)
{
if (customerId == null)
{
return null;
}
return customerMoneyMapper.selectAccountByCustomerCurrency(customerId, resolveCurrency(currency));
}
@Override
public CustomerAccount lockCnyAccount(Long customerId)
{
if (customerId == null)
{
throw new ServiceException("客户ID不能为空");
}
return getOrInitCnyAccount(customerId, SecurityUtils.getUsername());
}
@Override
@Transactional(rollbackFor = Exception.class)
public int initDefaultCnyAccount(Long customerId)
{
if (customerId == null)
{
throw new ServiceException("客户ID不能为空");
}
String username = SecurityUtils.getUsername();
CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (account != null)
{
return 1;
}
BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId);
if (customer == null)
{
throw new ServiceException("客户不存在");
}
BigDecimal initCredit = nz(customer.getCreditEd());
if (initCredit.compareTo(BigDecimal.ZERO) <= 0)
{
initCredit = DEFAULT_CREDIT;
}
BigDecimal initTemp = nz(customer.getTempEd());
account = createCnyAccount(customerId, initCredit, initTemp, username);
// 档案额度与实际初始额度保持一致
bmsCustomerMapper.updateCustomerCredit(customerId, initCredit, initTemp, username);
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT,
initCredit, null, null, username, "新客户默认初始化额度1000 CNY");
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
{
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
initTemp, null, null, username, "新客户初始化临时额度");
}
return 1;
}
@Override
public void expireTempCreditIfNeeded(CustomerAccount account)
{
if (account == null || !isTempCreditExpired(account))
{
return;
}
String username = SecurityUtils.getUsername();
BigDecimal before = nz(account.getExtraCredit());
account.setExtraCredit(BigDecimal.ZERO);
account.setUpdateBy(username);
int rows = customerMoneyMapper.updateAccountCredit(account);
if (rows == 0)
{
throw new ServiceException("账户额度已被其他操作变动,请刷新后重试");
}
account.setVersion(account.getVersion() + 1);
bmsCustomerMapper.updateCustomerCredit(account.getCustomerId(),
nz(account.getTotalCredit()), BigDecimal.ZERO, username);
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_TEMP);
flow.setChangeType(CustomerCreditFlow.CHANGE_EXPIRE);
flow.setDirection(-1);
flow.setBeforeAmount(before);
flow.setChangeAmount(before);
flow.setAfterAmount(BigDecimal.ZERO);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(account.getExtraEffectiveDate());
flow.setExpireDate(account.getExtraExpireDate());
flow.setReason("临时额度到期自动失效");
flow.setOperatorId(SecurityUtils.getUserId());
flow.setCreateBy(username);
flow.setRemark("系统在访问账户时检测到临时额度已过失效日期,自动置零");
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
/* ================= 授信额度占用 / 释放 ================= */
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerCreditFlow occupyCredit(CreditUsageCommand command)
{
validateUsageCommand(command);
String currency = resolveCurrency(command.getCurrency());
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForUsage(command.getCustomerId(), currency, username);
if (account == null)
{
// 外币账户尚未开立时不自动占用,与历史账单口径保持一致(CNY账户会自动初始化)
return null;
}
BigDecimal amount = money(command.getAmount());
BigDecimal beforeUsed = nz(account.getUsedCredit());
BigDecimal totalAvailable = nz(account.getTotalCredit()).add(nz(account.getExtraCredit()))
.subtract(beforeUsed);
if (Boolean.TRUE.equals(command.getCheckAvailable()) && totalAvailable.compareTo(amount) < 0)
{
throw new ServiceException("客户可用额度不足,当前可用额度:" + totalAvailable.toPlainString()
+ " " + currency + ",本次需占用:" + amount.toPlainString());
}
BigDecimal afterUsed = beforeUsed.add(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
account.setUsedCredit(afterUsed);
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
}
return writeUsedCreditFlow(account, command, CustomerCreditFlow.CHANGE_OCCUPY, 1,
beforeUsed, afterUsed, amount, username, userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerCreditFlow releaseCredit(CreditUsageCommand command)
{
validateUsageCommand(command);
String currency = resolveCurrency(command.getCurrency());
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForUsage(command.getCustomerId(), currency, username);
if (account == null)
{
return null;
}
BigDecimal amount = money(command.getAmount());
BigDecimal beforeUsed = nz(account.getUsedCredit());
BigDecimal afterUsed = beforeUsed.subtract(amount).max(BigDecimal.ZERO)
.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (afterUsed.compareTo(beforeUsed) == 0)
{
// 无实际占用可释放,仍返回null不产生流水
return null;
}
account.setUsedCredit(afterUsed);
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
}
return writeUsedCreditFlow(account, command, CustomerCreditFlow.CHANGE_RELEASE, -1,
beforeUsed, afterUsed, amount, username, userId);
}
/* ================= 余额扣减 / 回补 ================= */
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerBalanceFlow deductBalance(BalanceChangeCommand command)
{
validateBalanceCommand(command);
String flowCurrency = resolveCurrency(command.getCurrency());
String accountCurrency = StringUtils.hasText(command.getAccountCurrency())
? resolveCurrency(command.getAccountCurrency()) : flowCurrency;
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForChange(command.getCustomerId(), accountCurrency, username, "扣款");
BigDecimal originalAmount = money(command.getAmount());
// 账户币种实际变动额:折算入账时取本位币金额,其余场景等于原币金额
BigDecimal changeAmount = command.getBaseAmount() != null ? money(command.getBaseAmount()) : originalAmount;
BigDecimal beforeBalance = nz(account.getBalance());
if (!Boolean.FALSE.equals(command.getCheckBalanceEnough()) && beforeBalance.compareTo(changeAmount) < 0)
{
throw new ServiceException("账户余额不足,当前" + accountCurrency + "余额:" + beforeBalance.toPlainString()
+ ",本次需扣减:" + changeAmount.toPlainString());
}
BigDecimal afterBalance = beforeBalance.subtract(changeAmount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
account.setBalance(afterBalance);
if (Boolean.TRUE.equals(command.getReleaseUsedCredit()))
{
account.setUsedCredit(nz(account.getUsedCredit()).subtract(changeAmount).max(BigDecimal.ZERO)
.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
}
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
CustomerBalanceFlow flow = newBalanceFlow(account, command, flowCurrency, originalAmount, -1,
beforeBalance, afterBalance, frozenBalance, frozenBalance, username, userId);
customerMoneyMapper.insertFlow(flow);
return flow;
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerBalanceFlow refundBalance(BalanceChangeCommand command)
{
validateBalanceCommand(command);
String flowCurrency = resolveCurrency(command.getCurrency());
String accountCurrency = StringUtils.hasText(command.getAccountCurrency())
? resolveCurrency(command.getAccountCurrency()) : flowCurrency;
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForChange(command.getCustomerId(), accountCurrency, username, "回补");
BigDecimal originalAmount = money(command.getAmount());
// 账户币种实际变动额:折算入账时取本位币金额,其余场景等于原币金额
BigDecimal changeAmount = command.getBaseAmount() != null ? money(command.getBaseAmount()) : originalAmount;
BigDecimal beforeBalance = nz(account.getBalance());
BigDecimal afterBalance = beforeBalance.add(changeAmount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
account.setBalance(afterBalance);
if (Boolean.TRUE.equals(command.getReoccupyUsedCredit()))
{
// 反核销回补:账单恢复未清偿,授信占用同步加回(允许出现高于总额度的占用存量)
account.setUsedCredit(nz(account.getUsedCredit()).add(changeAmount)
.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
}
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
CustomerBalanceFlow flow = newBalanceFlow(account, command, flowCurrency, originalAmount, 1,
beforeBalance, afterBalance, frozenBalance, frozenBalance, username, userId);
customerMoneyMapper.insertFlow(flow);
return flow;
}
/* ================= 内部实现 ================= */
/** 额度占用/释放写额度流水(before/after 为已用额度快照) */
private CustomerCreditFlow writeUsedCreditFlow(CustomerAccount account, CreditUsageCommand command,
String changeType, int direction, BigDecimal beforeUsed, BigDecimal afterUsed,
BigDecimal amount, String username, Long userId)
{
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_CREDIT);
flow.setChangeType(changeType);
flow.setBizType(command.getBizType());
flow.setBizId(command.getBizId());
flow.setBizNo(command.getBizNo());
flow.setDirection(direction);
flow.setBeforeAmount(beforeUsed);
flow.setChangeAmount(amount);
flow.setAfterAmount(afterUsed);
flow.setUsedCredit(afterUsed);
flow.setReason(resolveUsageReason(command, changeType));
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
return flow;
}
/** 余额变动写余额流水(原币金额留痕,本位币金额参与余额变动) */
private CustomerBalanceFlow newBalanceFlow(CustomerAccount account, BalanceChangeCommand command,
String currency, BigDecimal amount, int direction,
BigDecimal beforeBalance, BigDecimal afterBalance,
BigDecimal beforeFrozen, BigDecimal afterFrozen,
String username, Long userId)
{
BigDecimal rate = command.getExchangeRate() != null
&& command.getExchangeRate().compareTo(BigDecimal.ZERO) > 0
? command.getExchangeRate() : BigDecimal.ONE;
BigDecimal baseAmount = command.getBaseAmount() != null ? command.getBaseAmount()
: amount.multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
CustomerBalanceFlow flow = new CustomerBalanceFlow();
flow.setFlowNo(generateNo("LS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCurrency(currency);
flow.setExchangeRate(rate.setScale(8, RoundingMode.HALF_UP));
flow.setBaseAmount(baseAmount);
flow.setBizType(command.getBizType());
flow.setBizId(command.getBizId());
flow.setBizNo(command.getBizNo());
flow.setDirection(direction);
flow.setAmount(amount);
flow.setBeforeBalance(beforeBalance);
flow.setAfterBalance(afterBalance);
flow.setBeforeFrozenBalance(beforeFrozen);
flow.setAfterFrozenBalance(afterFrozen);
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark(command.getRemark());
return flow;
}
/**
* 占用/释放场景锁账户:CNY 不存在自动初始化(先处理临时额度过期),
* 外币账户不存在返回null(调用方静默跳过)。
*/
private CustomerAccount lockAccountForUsage(Long customerId, String currency, String username)
{
if (BASE_CURRENCY.equals(currency))
{
CustomerAccount account = getOrInitCnyAccount(customerId, username);
expireTempCreditIfNeeded(account);
return account;
}
return customerMoneyMapper.selectAccountForUpdate(customerId, currency);
}
/**
* 余额变动场景锁账户:账户不存在直接抛异常(余额扣款/回补必须有账户)。
*/
private CustomerAccount lockAccountForChange(Long customerId, String currency, String username, String action)
{
CustomerAccount account;
if (BASE_CURRENCY.equals(currency))
{
account = getOrInitCnyAccount(customerId, username);
expireTempCreditIfNeeded(account);
}
else
{
account = customerMoneyMapper.selectAccountForUpdate(customerId, currency);
}
if (account == null)
{
throw new ServiceException("客户没有" + currency + "账户,无法" + action);
}
return account;
}
/** 行锁获取客户CNY账户,不存在则按客户档案当前额度初始化创建 */
private CustomerAccount getOrInitCnyAccount(Long customerId, String username)
{
CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (account == null)
{
BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId);
if (customer == null)
{
throw new ServiceException("客户不存在");
}
BigDecimal initCredit = nz(customer.getCreditEd());
BigDecimal initTemp = nz(customer.getTempEd());
account = createCnyAccount(customerId, initCredit, initTemp, username);
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT,
initCredit, null, null, username, "首次创建CNY账户,从客户档案同步初始额度");
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
{
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
initTemp, null, null, username, "首次创建CNY账户,从客户档案同步临时额度");
}
}
return account;
}
/** 创建CNY账户(余额0,已用0) */
private CustomerAccount createCnyAccount(Long customerId, BigDecimal totalCredit,
BigDecimal extraCredit, String username)
{
CustomerAccount account = new CustomerAccount();
account.setCustomerId(customerId);
account.setCurrency(BASE_CURRENCY);
account.setBalance(BigDecimal.ZERO);
account.setFrozenBalance(BigDecimal.ZERO);
account.setTotalCredit(totalCredit);
account.setExtraCredit(extraCredit);
account.setUsedCredit(BigDecimal.ZERO);
account.setTax(BigDecimal.ZERO);
account.setVersion(0);
account.setCreateBy(username);
customerMoneyMapper.insertAccount(account);
return account;
}
/** 账户首次初始化时写额度 INIT 流水(从客户档案额度同步) */
private void writeInitFlow(CustomerAccount account, String creditType,
BigDecimal initAmount, Date effectiveDate, Date expireDate,
String username, String remark)
{
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(creditType);
flow.setChangeType(CustomerCreditFlow.CHANGE_INIT);
flow.setDirection(initAmount.compareTo(BigDecimal.ZERO) > 0 ? 1 : 0);
flow.setBeforeAmount(BigDecimal.ZERO);
flow.setChangeAmount(initAmount);
flow.setAfterAmount(initAmount);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(effectiveDate);
flow.setExpireDate(expireDate);
flow.setReason("账户初始化");
flow.setOperatorId(SecurityUtils.getUserId());
flow.setCreateBy(username);
flow.setRemark(remark);
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
private void validateUsageCommand(CreditUsageCommand command)
{
if (command == null || command.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
if (!StringUtils.hasText(command.getBizType()))
{
throw new ServiceException("业务类型不能为空");
}
if (command.getAmount() == null || command.getAmount().compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("额度变动金额必须大于0");
}
}
private void validateBalanceCommand(BalanceChangeCommand command)
{
if (command == null || command.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
if (!StringUtils.hasText(command.getBizType()))
{
throw new ServiceException("业务类型不能为空");
}
if (command.getAmount() == null || command.getAmount().compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("余额变动金额必须大于0");
}
}
private String resolveUsageReason(CreditUsageCommand command, String changeType)
{
if (StringUtils.hasText(command.getReason()))
{
return command.getReason().trim();
}
String action = CustomerCreditFlow.CHANGE_OCCUPY.equals(changeType) ? "占用授信额度" : "释放授信额度";
String biz = bizTypeLabel(command.getBizType());
String reason = biz + action;
if (StringUtils.hasText(command.getBizNo()))
{
reason = reason + ":" + command.getBizNo();
}
return reason;
}
private String bizTypeLabel(String bizType)
{
if (CustomerFundBizType.AR_BILL.equals(bizType))
{
return "应收账单";
}
if (CustomerFundBizType.ORDER.equals(bizType))
{
return "计费订单";
}
if (CustomerFundBizType.BILL_PAY.equals(bizType))
{
return "账单核销";
}
return "业务";
}
/** 临时额度是否已过期:有失效日期且今天已晚于失效日(失效日当天仍有效) */
private boolean isTempCreditExpired(CustomerAccount account)
{
return account != null
&& nz(account.getExtraCredit()).compareTo(BigDecimal.ZERO) > 0
&& account.getExtraExpireDate() != null
&& LocalDate.now().isAfter(account.getExtraExpireDate().toInstant()
.atZone(java.time.ZoneId.systemDefault()).toLocalDate());
}
private String resolveCurrency(String currency)
{
return StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY;
}
private BigDecimal money(BigDecimal value)
{
return nz(value).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
}
/** 生成业务单号:前缀 + 时间戳(毫秒) + 3位随机数 */
private String generateNo(String prefix)
{
return prefix + LocalDateTime.now().format(NO_FORMATTER)
+ ThreadLocalRandom.current().nextInt(100, 1000);
}
private BigDecimal nz(BigDecimal value)
{
return value == null ? BigDecimal.ZERO : value;
}
}
@@ -0,0 +1,102 @@
package com.ruoyi.customer.customermoney.domain;
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_apply
* 信用额度/额外额度(临时额度)调整先申请,审批通过后才真正调整账户额度
*
* @author ruoyi
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CustomerCreditApply extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 待审批 */
public static final String STATUS_PENDING = "PENDING";
/** 已通过 */
public static final String STATUS_APPROVED = "APPROVED";
/** 已驳回 */
public static final String STATUS_REJECTED = "REJECTED";
/** 已撤销 */
public static final String STATUS_CANCELLED = "CANCELLED";
/** 申请单ID */
private Long applyId;
/** 申请单号(EDSQ前缀) */
private String applyNo;
/** 客户ID */
private Long customerId;
/** 客户账户ID */
private Long accountId;
/** 额度类型:CREDIT信用额度 TEMP临时额度(额外额度) */
private String creditType;
/** 调整方式:SET设置为 ADD增加 SUB减少 */
private String adjustMode;
/** 调整金额(正数) */
private java.math.BigDecimal changeAmount;
/** 临时额度生效日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date effectiveDate;
/** 临时额度失效日期(空=长期) */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date expireDate;
/** 调整原因 */
private String reason;
/** 状态:PENDING/APPROVED/REJECTED/CANCELLED */
private String status;
/** 申请人 */
private String applyBy;
/** 申请人ID */
private Long applyUserId;
/** 申请时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date applyTime;
/** 审批人 */
private String approveBy;
/** 审批人ID */
private Long approveUserId;
/** 审批时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date approveTime;
/** 审批意见/驳回原因 */
private String approveRemark;
/** 审批通过后生成的额度流水ID */
private Long flowId;
/** 删除标志 */
private String delFlag;
/* -------- 展示字段 -------- */
/** 客户编码 */
private String customerCode;
/** 客户名称 */
private String customerName;
}
@@ -30,6 +30,10 @@ public class CustomerCreditFlow extends BaseEntity
public static final String CHANGE_ADJUST = "ADJUST";
/** 变动类型:到期自动失效 */
public static final String CHANGE_EXPIRE = "EXPIRE";
/** 变动类型:业务单据占用授信(已用额度增加,如账单确认/计费订单开单) */
public static final String CHANGE_OCCUPY = "OCCUPY";
/** 变动类型:业务单据释放授信(已用额度减少,如账单作废/计费变动核减) */
public static final String CHANGE_RELEASE = "RELEASE";
/** 调整方式:设置为 */
public static final String MODE_SET = "SET";
@@ -53,9 +57,18 @@ public class CustomerCreditFlow extends BaseEntity
/** 额度类型:CREDIT信用额度 TEMP临时额度 */
private String creditType;
/** 变动类型:INIT初始化 ADJUST调整 EXPIRE到期失效 */
/** 变动类型:INIT初始化 ADJUST调整 EXPIRE到期失效 OCCUPY授信占用 RELEASE授信释放 */
private String changeType;
/** 业务来源类型:AR_BILL应收账单 ORDER计费订单(仅占用/释放流水) */
private String bizType;
/** 关联业务单据ID */
private Long bizId;
/** 关联业务单据号 */
private String bizNo;
/** 变动方向:1增加 -1减少 0持平 */
private Integer direction;
@@ -0,0 +1,35 @@
package com.ruoyi.customer.customermoney.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.customer.customermoney.domain.CustomerCreditApply;
/**
* 客户额度调整申请审批 Mapper
*
* @author ruoyi
* @date 2026-09-16
*/
public interface CustomerCreditApplyMapper
{
/** 新增申请 */
int insertApply(CustomerCreditApply apply);
/** 按ID查申请(行锁,审批事务使用) */
CustomerCreditApply selectApplyByIdForUpdate(@Param("applyId") Long applyId);
/** 按ID查申请 */
CustomerCreditApply selectApplyById(@Param("applyId") Long applyId);
/** 查询申请列表(可按客户/状态/类型过滤) */
List<CustomerCreditApply> selectApplyList(CustomerCreditApply query);
/** 审批通过:PENDING -> APPROVED,回写账户ID、流水ID、审批人/时间/意见 */
int updateApproved(CustomerCreditApply apply);
/** 审批驳回:PENDING -> REJECTED */
int updateRejected(CustomerCreditApply apply);
/** 申请人撤销:PENDING -> CANCELLED */
int updateCancelled(@Param("applyId") Long applyId, @Param("updateBy") String updateBy);
}
@@ -1,9 +1,9 @@
package com.ruoyi.customer.customermoney.service;
import java.math.BigDecimal;
import java.util.List;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
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;
@@ -52,41 +52,26 @@ public interface ICustomerMoneyService
List<CustomerCreditFlow> selectCreditFlowList(CustomerCreditFlow query);
/**
* 调整客户额度(信用额度/临时额度)
* 事务内:锁定CNY账户(不存在则初始化)-> 校验(不能为负、不能低于已用额度)
* -> 乐观锁更新额度 -> 回写客户档案额度字段 -> 写额度流水
* 提交额度调整申请(信用额度/临时额度)。申请仅落库为 PENDING,不立即变动账户额度;
* 申请时会按账户当前额度做一次合法性校验(不能为负、不能低于已用额度),审批通过时会再次校验。
*/
int adjustCredit(CustomerCreditFlow flow);
int applyCreditAdjust(CustomerCreditApply apply);
/**
* 账单确认时占用授信额度(used_credit += amount)。该币种账户不存在时跳过。
* 调用方必须已持有账单行锁,本方法内部再锁账户(加锁顺序:账单 -> 账户)。
* 审批通过额度调整申请:PENDING -> APPROVED。
* 事务内重新锁定CNY账户并按审批时的实际额度计算/校验,通过后调整额度、回写客户档案、写额度流水。
*/
void occupyCredit(Long customerId, String currency, BigDecimal amount);
int approveCreditApply(Long applyId, String approveRemark);
/**
* 账单作废时释放授信额度(used_credit -= amount,不小于0)。该币种账户不存在时跳过。
* 调用方必须已持有账单行锁。
*/
void releaseCredit(Long customerId, String currency, BigDecimal amount);
/** 审批驳回:PENDING -> REJECTED,驳回原因必填 */
int rejectCreditApply(Long applyId, String rejectReason);
/**
* 用账户余额核销账单:校验余额充足 -> balance -= amount、used_credit -= amount
* -> 写一条 BILL_PAY 扣款流水(direction=-1)。返回已落库的流水(含ID)。
*
* @param bizId 余额核销单ID
* @param bizNo 账单号
*/
CustomerBalanceFlow payBill(Long customerId, String currency, BigDecimal amount,
Long bizId, String bizNo, String remark);
/** 申请人撤销:PENDING -> CANCELLED */
int cancelCreditApply(Long applyId);
/**
* 反核销回补:balance += amount、used_credit += amount(重新占用授信)
* -> 写一条 BILL_PAY 回补流水(direction=1)。返回已落库的流水(含ID)。
*
* @param bizId 余额核销单ID
* @param bizNo 账单号
*/
CustomerBalanceFlow reverseBillPay(Long customerId, String currency, BigDecimal amount,
Long bizId, String bizNo, String remark);
/** 查询额度调整申请列表 */
List<CustomerCreditApply> selectCreditApplyList(CustomerCreditApply query);
/** 查询额度调整申请详情 */
CustomerCreditApply selectCreditApplyById(Long applyId);
}
@@ -18,10 +18,15 @@ import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.customer.customerinfo.domain.BmsCustomer;
import com.ruoyi.customer.customerinfo.domain.BmsCustomerAttachment;
import com.ruoyi.customer.customerinfo.mapper.BmsCustomerMapper;
import com.ruoyi.customer.customermoney.api.BalanceChangeCommand;
import com.ruoyi.customer.customermoney.api.CustomerFundBizType;
import com.ruoyi.customer.customermoney.api.ICustomerAccountApi;
import com.ruoyi.customer.customermoney.domain.CustomerAccount;
import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.domain.CustomerCreditApply;
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.ICustomerMoneyService;
import com.ruoyi.system.domain.BmsExchangeRate;
@@ -40,20 +45,25 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
/** 本位币币种 */
private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY;
/** 新客户默认总额度(CNY) */
private static final BigDecimal DEFAULT_CREDIT = new BigDecimal("1000");
/** 金额保留位数 */
private static final int MONEY_SCALE = 2;
@Autowired
private CustomerMoneyMapper customerMoneyMapper;
@Autowired
private CustomerCreditApplyMapper creditApplyMapper;
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
@Autowired
private IBmsExchangeRateService exchangeRateService;
/** 客户资金公共服务:账户/余额/授信额度变动统一收口 */
@Autowired
private ICustomerAccountApi customerAccountApi;
@Override
@Transactional(rollbackFor = Exception.class)
public List<CustomerAccount> selectAccountList(Long customerId)
@@ -65,8 +75,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
CustomerAccount account = list.get(i);
if (BASE_CURRENCY.equals(account.getCurrency()) && isTempCreditExpired(account))
{
CustomerAccount locked = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
expireTempCreditIfNeeded(locked);
CustomerAccount locked = customerAccountApi.lockCnyAccount(customerId);
customerAccountApi.expireTempCreditIfNeeded(locked);
if (locked != null)
{
list.set(i, locked);
@@ -188,46 +198,24 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
? receipt.getBaseAmount()
: originalAmount.multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
// 1. 行锁锁定客户本位币(CNY)账户,不存在则按客户档案额度自动初始化。无论收什么币种,一律折算 CNY 入账
CustomerAccount account = getOrInitCnyAccount(receipt.getCustomerId(), username);
// 2. 本位币余额变动(预充值/混合增加余额;还款核销释放已用额度)
BigDecimal beforeBalance = nz(account.getBalance());
BigDecimal afterBalance = beforeBalance.add(baseAmount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
account.setBalance(afterBalance);
account.setUpdateBy(username);
// 1. 本位币入账(公共资金服务内完成:锁户/初始化 -> 余额变动 -> 余额流水)。
// 额度占用统一以账单为准:收款审核只增加余额,核销账单时才释放已用额度
int accountRows = customerMoneyMapper.updateAccountBalance(account);
if (accountRows == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
BalanceChangeCommand fundCmd = new BalanceChangeCommand();
fundCmd.setCustomerId(receipt.getCustomerId());
// 流水保留收款原币;无论收什么币种,一律锁CNY账户折算入账
fundCmd.setCurrency(currency);
fundCmd.setAccountCurrency(BASE_CURRENCY);
fundCmd.setAmount(originalAmount);
fundCmd.setExchangeRate(rate);
fundCmd.setBaseAmount(baseAmount);
fundCmd.setBizType(CustomerFundBizType.RECEIPT);
fundCmd.setBizId(receipt.getId());
fundCmd.setBizNo(receipt.getReceiptNo());
fundCmd.setReoccupyUsedCredit(false);
fundCmd.setRemark("收款单审核通过:" + receipt.getReceiptNo());
customerAccountApi.refundBalance(fundCmd);
// 3. 写余额流水:原币金额留痕,本位币金额参与余额变动
CustomerBalanceFlow flow = new CustomerBalanceFlow();
flow.setFlowNo(generateNo("LS"));
flow.setCustomerId(receipt.getCustomerId());
flow.setAccountId(account.getId());
flow.setCurrency(currency);
flow.setExchangeRate(rate);
flow.setBaseAmount(baseAmount);
flow.setBizType(CustomerBalanceFlow.BIZ_RECEIPT);
flow.setBizId(receipt.getId());
flow.setBizNo(receipt.getReceiptNo());
flow.setDirection(1);
flow.setAmount(originalAmount);
flow.setBeforeBalance(beforeBalance);
flow.setAfterBalance(afterBalance);
flow.setBeforeFrozenBalance(frozenBalance);
flow.setAfterFrozenBalance(frozenBalance);
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark("收款单审核通过:" + receipt.getReceiptNo());
customerMoneyMapper.insertFlow(flow);
// 4. 收款单置为已确认
// 2. 收款单置为已确认
receipt.setStatus(CustomerReceipt.STATUS_CONFIRMED);
receipt.setConfirmedAt(new Date());
receipt.setConfirmedBy(userId);
@@ -268,53 +256,27 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
return customerMoneyMapper.selectCreditFlowList(query);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int adjustCredit(CustomerCreditFlow cmd)
/**
* 执行额度调整(额度申请审批通过后调用)。
* 锁CNY账户 -> 按审批时实际额度计算并校验 -> 乐观锁更新 -> 回写客户档案 -> 写额度流水。
* 返回已落库的额度流水(含ID)。
*/
private CustomerCreditFlow executeAdjustCredit(CustomerCreditFlow cmd)
{
if (cmd == null || cmd.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
Long customerId = cmd.getCustomerId();
String creditType = cmd.getCreditType();
if (!CustomerCreditFlow.CREDIT_TYPE_CREDIT.equals(creditType)
&& !CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType))
{
throw new ServiceException("额度类型不正确");
}
String adjustMode = cmd.getAdjustMode();
if (!CustomerCreditFlow.MODE_SET.equals(adjustMode)
&& !CustomerCreditFlow.MODE_ADD.equals(adjustMode)
&& !CustomerCreditFlow.MODE_SUB.equals(adjustMode))
{
throw new ServiceException("调整方式不正确");
}
BigDecimal input = nz(cmd.getChangeAmount());
if (input.compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("调整金额必须大于0");
}
if (!StringUtils.hasText(cmd.getReason()))
{
throw new ServiceException("请填写调整原因");
}
String reason = cmd.getReason().trim();
if (reason.length() > 255)
{
throw new ServiceException("调整原因不能超过255个字");
}
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
Long customerId = cmd.getCustomerId();
String creditType = cmd.getCreditType();
String adjustMode = cmd.getAdjustMode();
String reason = cmd.getReason();
boolean isTemp = CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType);
// 1. 行锁锁定CNY账户(不存在则按客户档案额度初始化创建);先处理临时额度过期
CustomerAccount account = getOrInitCnyAccount(customerId, username);
expireTempCreditIfNeeded(account);
// 1. 参数/日期/结果合法性校验并锁定CNY账户(不存在则按客户档案额度初始化);先处理临时额度过期
CustomerAccount account = validateAdjustCommand(cmd, username);
BigDecimal input = nz(cmd.getChangeAmount());
// 2. 计算调整结果
BigDecimal before = isTemp ? nz(account.getExtraCredit()) : nz(account.getTotalCredit());
BigDecimal otherCredit = isTemp ? nz(account.getTotalCredit()) : nz(account.getExtraCredit());
BigDecimal after;
switch (adjustMode)
{
@@ -329,40 +291,18 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
break;
}
after = after.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (after.compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("调整后额度不能为负数");
}
if (otherCredit.add(after).compareTo(nz(account.getUsedCredit())) < 0)
{
throw new ServiceException("调整后授信总额度不能小于已用额度:"
+ nz(account.getUsedCredit()).toPlainString());
}
if (after.compareTo(before) == 0)
{
throw new ServiceException("额度未发生变化,无需调整");
}
// 3. 临时额度有效期校验(不允许预约未来生效,失效日必须晚于生效日)
// 3. 临时额度有效期处理(不允许预约未来生效,失效日必须晚于生效日)
Date effectiveDate = null;
Date expireDate = null;
if (isTemp)
{
LocalDate today = LocalDate.now();
LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today;
if (eff.isAfter(today))
{
throw new ServiceException("生效日期不能晚于今天,需要未来生效请在生效当天再设置");
}
effectiveDate = toSqlDate(eff);
if (cmd.getExpireDate() != null)
{
LocalDate exp = toLocalDate(cmd.getExpireDate());
if (!exp.isAfter(eff))
{
throw new ServiceException("失效日期必须晚于生效日期");
}
expireDate = toSqlDate(exp);
expireDate = toSqlDate(toLocalDate(cmd.getExpireDate()));
}
account.setExtraCredit(after);
account.setExtraEffectiveDate(effectiveDate);
@@ -408,295 +348,278 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
flow.setRemark(cmd.getRemark());
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
return 1;
return flow;
}
/* ================= 账单联动:额度占用 / 余额核销 ================= */
/* ================= 额度调整:申请 / 审批 ================= */
@Override
@Transactional(rollbackFor = Exception.class)
public void occupyCredit(Long customerId, String currency, BigDecimal amount)
public List<CustomerCreditApply> selectCreditApplyList(CustomerCreditApply query)
{
if (customerId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0)
{
return;
}
String ccny = StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY;
CustomerAccount account = lockAccountForBill(customerId, ccny, SecurityUtils.getUsername());
if (account == null)
{
return;
}
account.setUsedCredit(nz(account.getUsedCredit()).add(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP));
account.setUpdateBy(SecurityUtils.getUsername());
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
}
return creditApplyMapper.selectApplyList(query);
}
@Override
public CustomerCreditApply selectCreditApplyById(Long applyId)
{
return creditApplyMapper.selectApplyById(applyId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void releaseCredit(Long customerId, String currency, BigDecimal amount)
public int applyCreditAdjust(CustomerCreditApply apply)
{
if (customerId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0)
{
return;
}
String ccny = StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY;
CustomerAccount account = lockAccountForBill(customerId, ccny, SecurityUtils.getUsername());
if (account == null)
{
return;
}
account.setUsedCredit(nz(account.getUsedCredit()).subtract(amount).max(BigDecimal.ZERO)
.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
account.setUpdateBy(SecurityUtils.getUsername());
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户已被其他操作变动,请刷新后重试");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerBalanceFlow payBill(Long customerId, String currency, BigDecimal amount,
Long bizId, String bizNo, String remark)
{
if (customerId == null)
if (apply == null || apply.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("核销金额必须大于0");
}
String ccny = StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY;
// 构造内部调整命令,复用统一的参数/日期/额度合法性校验(只校验,不写入)
CustomerCreditFlow cmd = new CustomerCreditFlow();
cmd.setCustomerId(apply.getCustomerId());
cmd.setCreditType(apply.getCreditType());
cmd.setAdjustMode(apply.getAdjustMode());
cmd.setChangeAmount(nz(apply.getChangeAmount()));
cmd.setReason(apply.getReason());
cmd.setRemark(apply.getRemark());
cmd.setEffectiveDate(apply.getEffectiveDate());
cmd.setExpireDate(apply.getExpireDate());
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForBill(customerId, ccny, username);
if (account == null)
{
throw new ServiceException("客户没有" + ccny + "账户,无法用余额核销");
}
BigDecimal beforeBalance = nz(account.getBalance());
if (beforeBalance.compareTo(amount) < 0)
{
throw new ServiceException("账户余额不足,当前" + ccny + "余额:" + beforeBalance.toPlainString());
}
BigDecimal afterBalance = beforeBalance.subtract(amount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
// 核销账单 = 应收被清偿,同步释放等额授信占用
account.setBalance(afterBalance);
account.setUsedCredit(nz(account.getUsedCredit()).subtract(amount).max(BigDecimal.ZERO)
.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
// 1. 参数校验 + 日期校验 + 按当前账户额度预演结果,提前拦截不可能通过的申请(不写入账户)
validateAdjustCommand(cmd, username);
apply.setChangeAmount(cmd.getChangeAmount());
apply.setReason(cmd.getReason());
CustomerBalanceFlow flow = newBalanceFlow(account, ccny, amount, -1,
beforeBalance, afterBalance, frozenBalance, frozenBalance,
bizId, bizNo, username, userId, remark);
customerMoneyMapper.insertFlow(flow);
return flow;
// 2. 待审批申请落库,不动账户额度
apply.setApplyNo(generateNo("EDSQ"));
apply.setStatus(CustomerCreditApply.STATUS_PENDING);
apply.setApplyBy(username);
apply.setApplyUserId(userId);
apply.setApplyTime(new Date());
apply.setCreateBy(username);
return creditApplyMapper.insertApply(apply);
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerBalanceFlow reverseBillPay(Long customerId, String currency, BigDecimal amount,
Long bizId, String bizNo, String remark)
public int approveCreditApply(Long applyId, String approveRemark)
{
if (customerId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0)
if (applyId == null)
{
throw new ServiceException("反核销金额不正确");
throw new ServiceException("申请单ID不能为空");
}
CustomerCreditApply apply = creditApplyMapper.selectApplyByIdForUpdate(applyId);
if (apply == null)
{
throw new ServiceException("额度调整申请不存在");
}
if (!CustomerCreditApply.STATUS_PENDING.equals(apply.getStatus()))
{
throw new ServiceException("该申请已处理,不能重复审批");
}
String ccny = StringUtils.hasText(currency) ? currency.trim().toUpperCase() : BASE_CURRENCY;
String username = SecurityUtils.getUsername();
Long userId = SecurityUtils.getUserId();
CustomerAccount account = lockAccountForBill(customerId, ccny, username);
if (account == null)
{
throw new ServiceException("客户" + ccny + "账户不存在,无法反核销");
}
BigDecimal beforeBalance = nz(account.getBalance());
BigDecimal afterBalance = beforeBalance.add(amount);
BigDecimal frozenBalance = nz(account.getFrozenBalance());
// 账单恢复为未核销,授信占用同步加回(允许与存量数据一样出现超出总额度的占用状态)
account.setBalance(afterBalance);
account.setUsedCredit(nz(account.getUsedCredit()).add(amount).setScale(MONEY_SCALE, RoundingMode.HALF_UP));
account.setUpdateBy(username);
if (customerMoneyMapper.updateAccountBalance(account) == 0)
{
throw new ServiceException("账户余额已被其他操作变动,请刷新后重试");
}
// 以审批时账户的实际额度重新计算并调整(申请期间额度可能已变动)
CustomerCreditFlow cmd = new CustomerCreditFlow();
cmd.setCustomerId(apply.getCustomerId());
cmd.setCreditType(apply.getCreditType());
cmd.setAdjustMode(apply.getAdjustMode());
cmd.setChangeAmount(nz(apply.getChangeAmount()));
cmd.setReason(apply.getReason());
cmd.setRemark(apply.getRemark());
cmd.setEffectiveDate(apply.getEffectiveDate());
cmd.setExpireDate(apply.getExpireDate());
CustomerCreditFlow flow = executeAdjustCredit(cmd);
CustomerBalanceFlow flow = newBalanceFlow(account, ccny, amount, 1,
beforeBalance, afterBalance, frozenBalance, frozenBalance,
bizId, bizNo, username, userId, remark);
customerMoneyMapper.insertFlow(flow);
return flow;
CustomerCreditApply update = new CustomerCreditApply();
update.setApplyId(applyId);
update.setAccountId(flow.getAccountId());
update.setFlowId(flow.getId());
update.setApproveBy(username);
update.setApproveUserId(userId);
update.setApproveRemark(approveRemark);
int rows = creditApplyMapper.updateApproved(update);
if (rows == 0)
{
throw new ServiceException("申请状态已变化,审批失败");
}
return rows;
}
/** 账单联动场景锁定账户:CNY不存在则初始化(先处理临时额度过期),外币账户不存在返回null */
private CustomerAccount lockAccountForBill(Long customerId, String currency, String username)
@Override
@Transactional(rollbackFor = Exception.class)
public int rejectCreditApply(Long applyId, String rejectReason)
{
if (BASE_CURRENCY.equals(currency))
if (applyId == null)
{
CustomerAccount account = getOrInitCnyAccount(customerId, username);
expireTempCreditIfNeeded(account);
return account;
throw new ServiceException("申请单ID不能为空");
}
return customerMoneyMapper.selectAccountForUpdate(customerId, currency);
if (!StringUtils.hasText(rejectReason))
{
throw new ServiceException("请填写驳回原因");
}
String reason = rejectReason.trim();
if (reason.length() > 255)
{
throw new ServiceException("驳回原因不能超过255个字");
}
CustomerCreditApply apply = creditApplyMapper.selectApplyByIdForUpdate(applyId);
if (apply == null)
{
throw new ServiceException("额度调整申请不存在");
}
if (!CustomerCreditApply.STATUS_PENDING.equals(apply.getStatus()))
{
throw new ServiceException("该申请已处理,不能重复审批");
}
CustomerCreditApply update = new CustomerCreditApply();
update.setApplyId(applyId);
update.setApproveBy(SecurityUtils.getUsername());
update.setApproveUserId(SecurityUtils.getUserId());
update.setApproveRemark(reason);
int rows = creditApplyMapper.updateRejected(update);
if (rows == 0)
{
throw new ServiceException("申请状态已变化,驳回失败");
}
return rows;
}
private CustomerBalanceFlow newBalanceFlow(CustomerAccount account, String currency, BigDecimal amount,
int direction, BigDecimal beforeBalance, BigDecimal afterBalance,
BigDecimal beforeFrozen, BigDecimal afterFrozen,
Long bizId, String bizNo, String username, Long userId, String remark)
@Override
@Transactional(rollbackFor = Exception.class)
public int cancelCreditApply(Long applyId)
{
CustomerBalanceFlow flow = new CustomerBalanceFlow();
flow.setFlowNo(generateNo("LS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCurrency(currency);
flow.setExchangeRate(BigDecimal.ONE);
flow.setBaseAmount(amount);
flow.setBizType(CustomerBalanceFlow.BIZ_BILL_PAY);
flow.setBizId(bizId);
flow.setBizNo(bizNo);
flow.setDirection(direction);
flow.setAmount(amount);
flow.setBeforeBalance(beforeBalance);
flow.setAfterBalance(afterBalance);
flow.setBeforeFrozenBalance(beforeFrozen);
flow.setAfterFrozenBalance(afterFrozen);
flow.setOperatorId(userId);
flow.setCreateBy(username);
flow.setRemark(remark);
return flow;
if (applyId == null)
{
throw new ServiceException("申请单ID不能为空");
}
CustomerCreditApply apply = creditApplyMapper.selectApplyByIdForUpdate(applyId);
if (apply == null)
{
throw new ServiceException("额度调整申请不存在");
}
if (!CustomerCreditApply.STATUS_PENDING.equals(apply.getStatus()))
{
throw new ServiceException("仅待审批的申请可以撤销");
}
int rows = creditApplyMapper.updateCancelled(applyId, SecurityUtils.getUsername());
if (rows == 0)
{
throw new ServiceException("申请状态已变化,撤销失败");
}
return rows;
}
/**
* 额度调整参数/日期/结果合法性校验(申请与审批共用)。
* 锁定CNY账户做预演但不写入,返回当前账户;规整后的金额/原因/日期回写到 cmd。
*/
private CustomerAccount validateAdjustCommand(CustomerCreditFlow cmd, String username)
{
if (cmd == null || cmd.getCustomerId() == null)
{
throw new ServiceException("客户ID不能为空");
}
String creditType = cmd.getCreditType();
if (!CustomerCreditFlow.CREDIT_TYPE_CREDIT.equals(creditType)
&& !CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType))
{
throw new ServiceException("额度类型不正确");
}
String adjustMode = cmd.getAdjustMode();
if (!CustomerCreditFlow.MODE_SET.equals(adjustMode)
&& !CustomerCreditFlow.MODE_ADD.equals(adjustMode)
&& !CustomerCreditFlow.MODE_SUB.equals(adjustMode))
{
throw new ServiceException("调整方式不正确");
}
BigDecimal input = nz(cmd.getChangeAmount());
if (input.compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("调整金额必须大于0");
}
if (!StringUtils.hasText(cmd.getReason()))
{
throw new ServiceException("请填写调整原因");
}
String reason = cmd.getReason().trim();
if (reason.length() > 255)
{
throw new ServiceException("调整原因不能超过255个字");
}
cmd.setChangeAmount(input);
cmd.setReason(reason);
boolean isTemp = CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType);
CustomerAccount account = customerAccountApi.lockCnyAccount(cmd.getCustomerId());
customerAccountApi.expireTempCreditIfNeeded(account);
BigDecimal before = isTemp ? nz(account.getExtraCredit()) : nz(account.getTotalCredit());
BigDecimal otherCredit = isTemp ? nz(account.getTotalCredit()) : nz(account.getExtraCredit());
BigDecimal after;
switch (adjustMode)
{
case CustomerCreditFlow.MODE_SET:
after = input;
break;
case CustomerCreditFlow.MODE_ADD:
after = before.add(input);
break;
default:
after = before.subtract(input);
break;
}
after = after.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (after.compareTo(BigDecimal.ZERO) < 0)
{
throw new ServiceException("调整后额度不能为负数");
}
if (otherCredit.add(after).compareTo(nz(account.getUsedCredit())) < 0)
{
throw new ServiceException("调整后授信总额度不能小于已用额度:"
+ nz(account.getUsedCredit()).toPlainString());
}
if (after.compareTo(before) == 0)
{
throw new ServiceException("额度未发生变化,无需调整");
}
if (isTemp)
{
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("失效日期必须晚于生效日期");
}
}
}
return account;
}
/* ================= 额度私有方法 ================= */
/**
* 新建客户时默认初始化CNY账户:余额0,总额度默认1000 CNY
* (客户档案已填信用额度且大于0时按档案值),并写 INIT 流水;已存在则跳过。
* 新建客户时默认初始化CNY账户(委托公共资金服务统一收口)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int initDefaultCnyAccount(Long customerId)
{
if (customerId == null)
{
throw new ServiceException("客户ID不能为空");
}
String username = SecurityUtils.getUsername();
CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (account != null)
{
return 1;
}
BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId);
if (customer == null)
{
throw new ServiceException("客户不存在");
}
BigDecimal initCredit = nz(customer.getCreditEd());
if (initCredit.compareTo(BigDecimal.ZERO) <= 0)
{
initCredit = DEFAULT_CREDIT;
}
BigDecimal initTemp = nz(customer.getTempEd());
account = createCnyAccount(customerId, initCredit, initTemp, username);
// 档案额度与实际初始额度保持一致
bmsCustomerMapper.updateCustomerCredit(customerId, initCredit, initTemp, username);
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT,
initCredit, null, null, username, "新客户默认初始化额度1000 CNY");
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
{
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
initTemp, null, null, username, "新客户初始化临时额度");
}
return 1;
return customerAccountApi.initDefaultCnyAccount(customerId);
}
/** 行锁获取客户CNY账户,不存在则按客户档案当前额度初始化创建 */
private CustomerAccount getOrInitCnyAccount(Long customerId, String username)
{
CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY);
if (account == null)
{
BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId);
if (customer == null)
{
throw new ServiceException("客户不存在");
}
BigDecimal initCredit = nz(customer.getCreditEd());
BigDecimal initTemp = nz(customer.getTempEd());
account = createCnyAccount(customerId, initCredit, initTemp, username);
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT,
initCredit, null, null, username, "首次创建CNY账户,从客户档案同步初始额度");
if (initTemp.compareTo(BigDecimal.ZERO) > 0)
{
writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP,
initTemp, null, null, username, "首次创建CNY账户,从客户档案同步临时额度");
}
}
return account;
}
/** 创建CNY账户(余额0,已用0) */
private CustomerAccount createCnyAccount(Long customerId, BigDecimal totalCredit,
BigDecimal extraCredit, String username)
{
CustomerAccount account = new CustomerAccount();
account.setCustomerId(customerId);
account.setCurrency(BASE_CURRENCY);
account.setBalance(BigDecimal.ZERO);
account.setFrozenBalance(BigDecimal.ZERO);
account.setTotalCredit(totalCredit);
account.setExtraCredit(extraCredit);
account.setUsedCredit(BigDecimal.ZERO);
account.setTax(BigDecimal.ZERO);
account.setVersion(0);
account.setCreateBy(username);
customerMoneyMapper.insertAccount(account);
return account;
}
/**
* 账户首次初始化时写额度 INIT 流水(从客户档案额度同步)
*/
private void writeInitFlow(CustomerAccount account, String creditType,
BigDecimal initAmount, Date effectiveDate, Date expireDate,
String username, String remark)
{
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(creditType);
flow.setChangeType(CustomerCreditFlow.CHANGE_INIT);
flow.setDirection(initAmount.compareTo(BigDecimal.ZERO) > 0 ? 1 : 0);
flow.setBeforeAmount(BigDecimal.ZERO);
flow.setChangeAmount(initAmount);
flow.setAfterAmount(initAmount);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(effectiveDate);
flow.setExpireDate(expireDate);
flow.setReason("账户初始化");
flow.setOperatorId(SecurityUtils.getUserId());
flow.setCreateBy(username);
flow.setRemark(remark);
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
/** 临时额度是否已过期:有失效日期且今天已晚于失效日(失效日当天仍有效) */
/** 临时额度是否已过期:有失效日期且今天已晚于失效日(失效日当天仍有效)。仅只读判断,不落库 */
private boolean isTempCreditExpired(CustomerAccount account)
{
return account != null
@@ -705,50 +628,6 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
&& LocalDate.now().isAfter(toLocalDate(account.getExtraExpireDate()));
}
/**
* 临时额度到期自动失效:额度清零 -> 回写客户档案 -> 写EXPIRE流水。
* 入参必须是事务内行锁查出的账户。
*/
private void expireTempCreditIfNeeded(CustomerAccount account)
{
if (account == null || !isTempCreditExpired(account))
{
return;
}
String username = SecurityUtils.getUsername();
BigDecimal before = nz(account.getExtraCredit());
account.setExtraCredit(BigDecimal.ZERO);
account.setUpdateBy(username);
int rows = customerMoneyMapper.updateAccountCredit(account);
if (rows == 0)
{
throw new ServiceException("账户额度已被其他操作变动,请刷新后重试");
}
account.setVersion(account.getVersion() + 1);
bmsCustomerMapper.updateCustomerCredit(account.getCustomerId(),
nz(account.getTotalCredit()), BigDecimal.ZERO, username);
CustomerCreditFlow flow = new CustomerCreditFlow();
flow.setFlowNo(generateNo("EDLS"));
flow.setCustomerId(account.getCustomerId());
flow.setAccountId(account.getId());
flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_TEMP);
flow.setChangeType(CustomerCreditFlow.CHANGE_EXPIRE);
flow.setDirection(-1);
flow.setBeforeAmount(before);
flow.setChangeAmount(before);
flow.setAfterAmount(BigDecimal.ZERO);
flow.setUsedCredit(nz(account.getUsedCredit()));
flow.setEffectiveDate(account.getExtraEffectiveDate());
flow.setExpireDate(account.getExtraExpireDate());
flow.setReason("临时额度到期自动失效");
flow.setOperatorId(SecurityUtils.getUserId());
flow.setCreateBy(username);
flow.setRemark("系统在访问账户时检测到临时额度已过失效日期,自动置零");
flow.setDelFlag("0");
customerMoneyMapper.insertCreditFlow(flow);
}
private LocalDate toLocalDate(Date date)
{
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();