Merge branch 'master' of http://git.lionquexpress.com/wuweihua/BMS
This commit is contained in:
@@ -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;
|
||||
}
|
||||
-35
@@ -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);
|
||||
}
|
||||
+1
-2
@@ -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
-6
@@ -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()));
|
||||
|
||||
+23
-7
@@ -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(); }
|
||||
|
||||
-275
@@ -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; } }
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
+27
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
+566
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
@@ -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;
|
||||
}
|
||||
+14
-1
@@ -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;
|
||||
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
+16
-31
@@ -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);
|
||||
}
|
||||
|
||||
+269
-390
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user