客户余额加账单核销

This commit is contained in:
luojiaquan
2026-09-16 15:19:05 +08:00
parent f3be74b7a4
commit 2e39440540
15 changed files with 1639 additions and 5 deletions
@@ -0,0 +1,111 @@
package com.ruoyi.web.controller.customer.customermoney;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.ar.domain.ArBalanceWriteoff;
import com.ruoyi.ar.domain.ArBill;
import com.ruoyi.ar.service.IArBalanceWriteoffService;
import com.ruoyi.ar.service.IArBillService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
/**
* 客户余额核销应收账单 / 账单只读查询 Controller
*
* @author ruoyi
* @date 2026-09-16
*/
@RestController
@RequestMapping("/customersupplier/customerbill")
public class CustomerBillController extends BaseController
{
@Autowired
private IArBalanceWriteoffService balanceWriteoffService;
@Autowired
private IArBillService billService;
/**
* 查询客户可核销账单(已确认/部分核销,未核销额大于0)
*/
@PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:list')")
@GetMapping("/unsettled/{customerId}")
public AjaxResult unsettled(@PathVariable("customerId") Long customerId)
{
return success(balanceWriteoffService.selectUnsettledBills(customerId));
}
/**
* 查询余额核销记录(分页)
*/
@PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:list')")
@GetMapping("/writeoff/list")
public TableDataInfo writeoffList(ArBalanceWriteoff query)
{
if (query == null || query.getCustomerId() == null || query.getCustomerId() == 0L)
{
return getDataTable(Collections.emptyList());
}
startPage();
List<ArBalanceWriteoff> list = balanceWriteoffService.selectWriteoffList(query);
return getDataTable(list);
}
/**
* 用客户账户余额核销账单
*/
@PreAuthorize("@ss.hasPermi('customer:bill:writeoff')")
@Log(title = "余额核销账单", businessType = BusinessType.UPDATE)
@PostMapping("/writeoff")
public AjaxResult writeoff(@RequestBody ArBalanceWriteoff cmd)
{
cmd.setCreateBy(getUsername());
return success(balanceWriteoffService.writeoff(cmd));
}
/**
* 反核销(取消一笔有效余额核销)
*/
@PreAuthorize("@ss.hasPermi('customer:bill:writeoff:cancel')")
@Log(title = "余额核销反核销", businessType = BusinessType.UPDATE)
@PutMapping("/writeoff/{writeoffId}/cancel")
public AjaxResult cancelWriteoff(@PathVariable("writeoffId") Long writeoffId,
@RequestBody(required = false) ArBalanceWriteoff body)
{
balanceWriteoffService.cancelWriteoff(writeoffId, body == null ? null : body.getCancelRemark());
return success();
}
/**
* 账单查看:分页查询应收账单(只读,供客户侧账单查看页使用)
*/
@PreAuthorize("@ss.hasPermi('customer:bill:list')")
@GetMapping("/list")
public TableDataInfo billList(ArBill query)
{
startPage();
return getDataTable(billService.selectArBillList(query));
}
/**
* 账单查看:账单详情(含订单明细/费用明细)
*/
@PreAuthorize("@ss.hasPermi('customer:bill:query')")
@GetMapping("/{arBillId}")
public AjaxResult billInfo(@PathVariable("arBillId") Long arBillId)
{
return success(billService.selectArBillById(arBillId));
}
}
@@ -0,0 +1,87 @@
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;
/**
* 应收账单余额核销 ar_balance_writeoff
*
* <p>用客户账户余额核销应收账单,与 ar_receipt_writeoff(收款核销)并列,
* 两者共享 ar_bill.written_off_amount / unwritten_amount 缓存。</p>
*
* @author ruoyi
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArBalanceWriteoff extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 有效 */
public static final String STATUS_EFFECTIVE = "EFFECTIVE";
/** 已取消(反核销) */
public static final String STATUS_CANCELLED = "CANCELLED";
/** 余额核销ID */
private Long writeoffId;
/** 核销单号(BWO开头) */
private String writeoffNo;
/** 应收账单ID */
private Long arBillId;
/** 账单号快照 */
private String billNo;
/** 客户ID */
private Long customerId;
/** 客户账户ID */
private Long accountId;
/** 币种 */
private String currency;
/** 本次核销金额 */
private BigDecimal writeoffAmount;
/** 核销扣款余额流水ID */
private Long balanceFlowId;
/** 反核销回补余额流水ID */
private Long reverseFlowId;
/** 核销日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date writeoffDate;
/** 状态:EFFECTIVE/CANCELLED */
private String status;
/** 核销备注 */
private String remark;
/** 反核销人 */
private String cancelBy;
/** 反核销时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date cancelTime;
/** 反核销原因 */
private String cancelRemark;
/* ===== 列表关联展示字段(来自 ar_bill) ===== */
/** 账单名称 */
private String billName;
/** 客户名称快照 */
private String customerName;
}
@@ -0,0 +1,34 @@
package com.ruoyi.ar.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArBalanceWriteoff;
/**
* 应收账单余额核销 Mapper
*
* @author ruoyi
* @date 2026-09-16
*/
public interface ArBalanceWriteoffMapper
{
/** 新增余额核销单 */
int insertArBalanceWriteoff(ArBalanceWriteoff writeoff);
/** 回写扣款账户与余额流水ID */
int updateFlowRefs(@Param("writeoffId") Long writeoffId,
@Param("accountId") Long accountId,
@Param("balanceFlowId") Long balanceFlowId);
/** 按ID查询(行锁) */
ArBalanceWriteoff selectByIdForUpdate(@Param("writeoffId") Long writeoffId);
/** 反核销:置为CANCELLED并登记回补流水与原因 */
int updateCancelled(@Param("writeoffId") Long writeoffId,
@Param("reverseFlowId") Long reverseFlowId,
@Param("cancelBy") String cancelBy,
@Param("cancelRemark") String cancelRemark);
/** 查询核销记录列表(关联账单名称/客户名称) */
List<ArBalanceWriteoff> selectWriteoffList(ArBalanceWriteoff query);
}
@@ -19,6 +19,9 @@ public interface ArBillMapper
ArBill selectArBillByIdForUpdate(Long arBillId);
/** 查询客户可核销账单(已确认/部分核销),按账单日期升序,供余额核销选择 */
List<ArBill> selectUnsettledByCustomer(@Param("customerId") Long customerId);
int insertArBill(ArBill bill);
int updateArBillStatus(@Param("arBillId") Long arBillId, @Param("status") String status,
@@ -0,0 +1,30 @@
package com.ruoyi.ar.service;
import java.util.List;
import com.ruoyi.ar.domain.ArBalanceWriteoff;
import com.ruoyi.ar.domain.ArBill;
/**
* 应收账单余额核销 Service接口
*
* <p>资金来源为客户账户余额(customer_account.balance),与收款核销
* (ar_receipt_writeoff)并列;核销时三方联动:账户余额扣减、已用额度释放、
* 账单已核销/未核销金额及状态更新。</p>
*
* @author ruoyi
* @date 2026-09-16
*/
public interface IArBalanceWriteoffService
{
/** 查询客户可核销账单(已确认/部分核销,未核销额大于0) */
List<ArBill> selectUnsettledBills(Long customerId);
/** 查询余额核销记录 */
List<ArBalanceWriteoff> selectWriteoffList(ArBalanceWriteoff query);
/** 用客户账户余额核销账单 */
ArBalanceWriteoff writeoff(ArBalanceWriteoff cmd);
/** 反核销(取消一笔有效余额核销,余额回补、账单金额回退) */
void cancelWriteoff(Long writeoffId, String remark);
}
@@ -0,0 +1,249 @@
package com.ruoyi.ar.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
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.ar.domain.ArBalanceWriteoff;
import com.ruoyi.ar.domain.ArBill;
import com.ruoyi.ar.domain.ArBillLog;
import com.ruoyi.ar.enums.ArBillStatus;
import com.ruoyi.ar.enums.ArLogResult;
import com.ruoyi.ar.mapper.ArBalanceWriteoffMapper;
import com.ruoyi.ar.mapper.ArBillMapper;
import com.ruoyi.ar.service.IArBalanceWriteoffService;
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.domain.CustomerBalanceFlow;
import com.ruoyi.customer.customermoney.service.ICustomerMoneyService;
/**
* 应收账单余额核销 Service实现
*
* @author ruoyi
* @date 2026-09-16
*/
@Service
public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
{
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
private static final int MONEY_SCALE = 2;
@Autowired
private ArBillMapper billMapper;
@Autowired
private ArBalanceWriteoffMapper writeoffMapper;
@Autowired
private IArBillLogService logService;
@Autowired
private ICustomerMoneyService customerMoneyService;
@Override
public List<ArBill> selectUnsettledBills(Long customerId)
{
if (customerId == null)
{
return List.of();
}
return billMapper.selectUnsettledByCustomer(customerId);
}
@Override
public List<ArBalanceWriteoff> selectWriteoffList(ArBalanceWriteoff query)
{
return writeoffMapper.selectWriteoffList(query);
}
@Override
@Transactional(rollbackFor = Exception.class)
public ArBalanceWriteoff writeoff(ArBalanceWriteoff cmd)
{
if (cmd == null || cmd.getArBillId() == null)
{
throw new ServiceException("请选择要核销的账单");
}
BigDecimal amount = cmd.getWriteoffAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("核销金额必须大于0");
}
amount = amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
// 1. 行锁锁定账单(加锁顺序:账单 -> 账户,与反核销保持一致)
ArBill bill = billMapper.selectArBillByIdForUpdate(cmd.getArBillId());
if (bill == null)
{
throw new ServiceException("应收账单不存在");
}
ArBillStatus status = ArBillStatus.of(bill.getStatus());
if (status == null || !status.canWriteOff())
{
throw new ServiceException("当前账单状态不允许核销:" + (status == null ? bill.getStatus() : status.getLabel()));
}
if (cmd.getCustomerId() != null && !cmd.getCustomerId().equals(bill.getCustomerId()))
{
throw new ServiceException("账单与所选客户不匹配");
}
BigDecimal unwritten = nvl(bill.getUnwrittenAmount());
if (amount.compareTo(unwritten) > 0)
{
throw new ServiceException("核销金额超过账单未核销金额:" + unwritten.toPlainString());
}
String operator = SecurityUtils.getUsername();
String requestId = IdUtils.fastSimpleUUID();
String currency = StringUtils.hasText(bill.getCurrency()) ? bill.getCurrency().trim().toUpperCase() : "CNY";
Date writeoffDate = cmd.getWriteoffDate() != null ? cmd.getWriteoffDate() : new Date();
String fromStatus = bill.getStatus();
// 2. 先落核销单(账户与流水引用拿到后回写)
ArBalanceWriteoff writeoff = new ArBalanceWriteoff();
writeoff.setWriteoffNo(generateNo());
writeoff.setArBillId(bill.getArBillId());
writeoff.setBillNo(bill.getBillNo());
writeoff.setCustomerId(bill.getCustomerId());
writeoff.setCurrency(currency);
writeoff.setWriteoffAmount(amount);
writeoff.setWriteoffDate(writeoffDate);
writeoff.setStatus(ArBalanceWriteoff.STATUS_EFFECTIVE);
writeoff.setRemark(StringUtils.hasText(cmd.getRemark()) ? cmd.getRemark().trim() : null);
writeoff.setCreateBy(operator);
writeoffMapper.insertArBalanceWriteoff(writeoff);
// 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);
writeoffMapper.updateFlowRefs(writeoff.getWriteoffId(), flow.getAccountId(), flow.getId());
// 4. 更新账单已核销/未核销缓存与状态(乐观条件更新,状态被并发改动则整体回滚)
BigDecimal written = nvl(bill.getWrittenOffAmount()).add(amount);
BigDecimal newUnwritten = unwritten.subtract(amount);
String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus);
if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten,
nextStatus, operator, fromStatus) != 1)
{
throw new ServiceException("账单状态已发生变化,请刷新后重试");
}
recordLog(bill.getArBillId(), writeoff.getWriteoffId(), "BALANCE_WRITEOFF",
"账户余额核销账单 " + amount.toPlainString() + " " + currency,
fromStatus, nextStatus, operator, requestId);
writeoff.setAccountId(flow.getAccountId());
writeoff.setBalanceFlowId(flow.getId());
return writeoff;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelWriteoff(Long writeoffId, String remark)
{
if (writeoffId == null)
{
throw new ServiceException("核销记录不存在");
}
// 1. 锁核销单 -> 锁账单(与核销相同的加锁顺序)
ArBalanceWriteoff writeoff = writeoffMapper.selectByIdForUpdate(writeoffId);
if (writeoff == null)
{
throw new ServiceException("核销记录不存在");
}
if (!ArBalanceWriteoff.STATUS_EFFECTIVE.equals(writeoff.getStatus()))
{
throw new ServiceException("只有有效状态的核销记录可以反核销");
}
ArBill bill = billMapper.selectArBillByIdForUpdate(writeoff.getArBillId());
if (bill == null)
{
throw new ServiceException("核销关联的账单不存在");
}
String operator = SecurityUtils.getUsername();
String requestId = IdUtils.fastSimpleUUID();
String cancelRemark = StringUtils.hasText(remark) ? remark.trim() : "取消余额核销";
String fromStatus = bill.getStatus();
// 2. 余额回补、已用额度重新占用,写反向 BILL_PAY 流水
String flowRemark = "反核销回补账单:" + bill.getBillNo() + "(" + cancelRemark + ")";
CustomerBalanceFlow reverseFlow = customerMoneyService.reverseBillPay(writeoff.getCustomerId(),
writeoff.getCurrency(), nvl(writeoff.getWriteoffAmount()), writeoffId, bill.getBillNo(), flowRemark);
// 3. 账单核销金额回退,状态按剩余已核销额重算(归零则回到已确认)
BigDecimal written = nvl(bill.getWrittenOffAmount()).subtract(nvl(writeoff.getWriteoffAmount()));
if (written.compareTo(BigDecimal.ZERO) < 0)
{
written = BigDecimal.ZERO;
}
BigDecimal newUnwritten = nvl(bill.getTotalAmount()).subtract(written);
String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus);
if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten,
nextStatus, operator, fromStatus) != 1)
{
throw new ServiceException("账单状态已发生变化,请刷新后重试");
}
// 4. 核销单置为CANCELLED并登记回补流水
if (writeoffMapper.updateCancelled(writeoffId, reverseFlow.getId(), operator, cancelRemark) != 1)
{
throw new ServiceException("核销记录状态已变化,请刷新后重试");
}
recordLog(bill.getArBillId(), writeoffId, "CANCEL_BALANCE_WRITEOFF",
"反核销回补账单:" + cancelRemark,
fromStatus, nextStatus, operator, requestId);
}
/** 账单状态重算:已核销归零(非草稿)-> 已确认;足额 -> 已核销;否则部分核销 */
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 void recordLog(Long billId, Long writeoffId, String operationType, String desc,
String fromStatus, String toStatus, String operator, String requestId)
{
ArBillLog log = new ArBillLog();
log.setArBillId(billId);
log.setObjectType("BALANCE_WRITEOFF");
log.setObjectId(String.valueOf(writeoffId));
log.setOperationType(operationType);
log.setOperationDesc(desc);
log.setFromStatus(fromStatus);
log.setToStatus(toStatus);
log.setOperatorId(operator);
log.setOperatorName(operator);
log.setOperationSource("WEB");
log.setRequestId(requestId);
log.setResult(ArLogResult.SUCCESS.getCode());
logService.record(log);
}
private String generateNo()
{
return "BWO" + LocalDateTime.now().format(NO_FORMATTER)
+ ThreadLocalRandom.current().nextInt(100, 1000);
}
private BigDecimal nvl(BigDecimal value)
{
return value == null ? BigDecimal.ZERO : value;
}
}
@@ -57,6 +57,7 @@ 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.price.domain.PrcIncomeBillFee;
import com.ruoyi.price.domain.PrcOrderBill;
import com.ruoyi.price.mapper.PrcIncomeBillFeeMapper;
@@ -79,6 +80,7 @@ public class ArBillServiceImpl implements IArBillService
@Autowired private IArBillLogService logService;
@Autowired private PrcOrderBillMapper sourceBillMapper;
@Autowired private PrcIncomeBillFeeMapper sourceFeeMapper;
@Autowired private ICustomerMoneyService customerMoneyService;
@Override
public List<ArBill> selectArBillList(ArBill query)
@@ -276,6 +278,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()));
recordSuccess(bill, "CONFIRM", "确认应收账单", ArBillStatus.DRAFT.getCode(), ArBillStatus.CONFIRMED.getCode(), requestId, operator);
return selectArBillById(arBillId);
}
@@ -313,6 +318,12 @@ public class ArBillServiceImpl implements IArBillService
throw failure(arBillId, "BILL", String.valueOf(arBillId), "VOID", bill.getStatus(),
"账单状态已发生变化,请刷新后重试", operator, requestId);
}
// 账单作废:已确认账单占用的授信额度全额释放(可作废的CONFIRMED账单必然零核销)
if (currentStatus == ArBillStatus.CONFIRMED)
{
customerMoneyService.releaseCredit(bill.getCustomerId(), bill.getCurrency(),
nvl(bill.getTotalAmount()));
}
if (!sourceBillIds.isEmpty())
{
int releasedCount = sourceBillMapper.releaseArBillOrders(sourceBillIds, operator,
@@ -1,5 +1,6 @@
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;
@@ -56,4 +57,36 @@ public interface ICustomerMoneyService
* -> 乐观锁更新额度 -> 回写客户档案额度字段 -> 写额度流水
*/
int adjustCredit(CustomerCreditFlow flow);
/**
* 账单确认时占用授信额度(used_credit += amount)。该币种账户不存在时跳过。
* 调用方必须已持有账单行锁,本方法内部再锁账户(加锁顺序:账单 -> 账户)。
*/
void occupyCredit(Long customerId, String currency, BigDecimal amount);
/**
* 账单作废时释放授信额度(used_credit -= amount,不小于0)。该币种账户不存在时跳过。
* 调用方必须已持有账单行锁。
*/
void releaseCredit(Long customerId, String currency, BigDecimal amount);
/**
* 用账户余额核销账单:校验余额充足 -> 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);
/**
* 反核销回补: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);
}
@@ -197,12 +197,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
BigDecimal frozenBalance = nz(account.getFrozenBalance());
account.setBalance(afterBalance);
if (CustomerReceipt.TYPE_REPAY.equals(receipt.getReceiptType()))
{
account.setUsedCredit(nz(account.getUsedCredit()).subtract(baseAmount).max(BigDecimal.ZERO));
}
account.setUpdateBy(username);
// 额度占用统一以账单为准:收款审核只增加余额,核销账单时才释放已用额度
int accountRows = customerMoneyMapper.updateAccountBalance(account);
if (accountRows == 0)
{
@@ -415,6 +411,175 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
return 1;
}
/* ================= 账单联动:额度占用 / 余额核销 ================= */
@Override
@Transactional(rollbackFor = Exception.class)
public void occupyCredit(Long customerId, String currency, BigDecimal amount)
{
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("账户已被其他操作变动,请刷新后重试");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void releaseCredit(Long customerId, String currency, BigDecimal amount)
{
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)
{
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;
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("账户余额已被其他操作变动,请刷新后重试");
}
CustomerBalanceFlow flow = newBalanceFlow(account, ccny, amount, -1,
beforeBalance, afterBalance, frozenBalance, frozenBalance,
bizId, bizNo, username, userId, remark);
customerMoneyMapper.insertFlow(flow);
return flow;
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerBalanceFlow reverseBillPay(Long customerId, String currency, BigDecimal amount,
Long bizId, String bizNo, String remark)
{
if (customerId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0)
{
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("账户余额已被其他操作变动,请刷新后重试");
}
CustomerBalanceFlow flow = newBalanceFlow(account, ccny, amount, 1,
beforeBalance, afterBalance, frozenBalance, frozenBalance,
bizId, bizNo, username, userId, remark);
customerMoneyMapper.insertFlow(flow);
return flow;
}
/** 账单联动场景锁定账户:CNY不存在则初始化(先处理临时额度过期),外币账户不存在返回null */
private CustomerAccount lockAccountForBill(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 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)
{
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;
}
/* ================= 额度私有方法 ================= */
/**
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBalanceWriteoffMapper">
<resultMap id="ArBalanceWriteoffResult" type="ArBalanceWriteoff">
<id property="writeoffId" column="writeoff_id"/>
<result property="writeoffNo" column="writeoff_no"/>
<result property="arBillId" column="ar_bill_id"/>
<result property="billNo" column="bill_no"/>
<result property="customerId" column="customer_id"/>
<result property="accountId" column="account_id"/>
<result property="currency" column="currency"/>
<result property="writeoffAmount" column="writeoff_amount"/>
<result property="balanceFlowId" column="balance_flow_id"/>
<result property="reverseFlowId" column="reverse_flow_id"/>
<result property="writeoffDate" column="writeoff_date"/>
<result property="status" column="status"/>
<result property="remark" column="remark"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="cancelBy" column="cancel_by"/>
<result property="cancelTime" column="cancel_time"/>
<result property="cancelRemark" column="cancel_remark"/>
<result property="billName" column="bill_name"/>
<result property="customerName" column="customer_name"/>
</resultMap>
<sql id="selectColumns">
select w.writeoff_id, w.writeoff_no, w.ar_bill_id, w.bill_no, w.customer_id, w.account_id,
w.currency, w.writeoff_amount, w.balance_flow_id, w.reverse_flow_id,
w.writeoff_date, w.status, w.remark, w.create_by, w.create_time,
w.cancel_by, w.cancel_time, w.cancel_remark,
b.bill_name, b.customer_name
from ar_balance_writeoff w
left join ar_bill b on b.ar_bill_id = w.ar_bill_id
</sql>
<insert id="insertArBalanceWriteoff" parameterType="ArBalanceWriteoff"
useGeneratedKeys="true" keyProperty="writeoffId">
insert into ar_balance_writeoff (
writeoff_no, ar_bill_id, bill_no, customer_id, account_id, currency,
writeoff_amount, balance_flow_id, reverse_flow_id, writeoff_date,
status, remark, create_by, create_time
) values (
#{writeoffNo}, #{arBillId}, #{billNo}, #{customerId}, #{accountId}, #{currency},
#{writeoffAmount}, #{balanceFlowId}, #{reverseFlowId}, #{writeoffDate},
#{status}, #{remark}, #{createBy}, sysdate()
)
</insert>
<update id="updateFlowRefs">
update ar_balance_writeoff
set account_id = #{accountId},
balance_flow_id = #{balanceFlowId}
where writeoff_id = #{writeoffId}
</update>
<select id="selectByIdForUpdate" resultMap="ArBalanceWriteoffResult">
<include refid="selectColumns"/>
where w.writeoff_id = #{writeoffId}
for update
</select>
<update id="updateCancelled">
update ar_balance_writeoff
set status = 'CANCELLED',
reverse_flow_id = #{reverseFlowId},
cancel_by = #{cancelBy},
cancel_time = sysdate(),
cancel_remark = #{cancelRemark}
where writeoff_id = #{writeoffId} and status = 'EFFECTIVE'
</update>
<select id="selectWriteoffList" parameterType="ArBalanceWriteoff" resultMap="ArBalanceWriteoffResult">
<include refid="selectColumns"/>
<where>
<if test="customerId != null">and w.customer_id = #{customerId}</if>
<if test="arBillId != null">and w.ar_bill_id = #{arBillId}</if>
<if test="status != null and status != ''">and w.status = #{status}</if>
<if test="currency != null and currency != ''">and w.currency = #{currency}</if>
<if test="writeoffNo != null and writeoffNo != ''">and w.writeoff_no like concat('%', #{writeoffNo}, '%')</if>
<if test="billNo != null and billNo != ''">and w.bill_no like concat('%', #{billNo}, '%')</if>
</where>
order by w.create_time desc, w.writeoff_id desc
</select>
</mapper>
@@ -80,6 +80,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="selectArBillColumns"/> where ar_bill_id = #{arBillId} for update
</select>
<select id="selectUnsettledByCustomer" resultMap="ArBillResult">
<include refid="selectArBillColumns"/>
where customer_id = #{customerId}
and status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF')
and unwritten_amount &gt; 0
order by bill_date asc, ar_bill_id asc
</select>
<insert id="insertArBill" parameterType="ArBill" useGeneratedKeys="true" keyProperty="arBillId">
insert into ar_bill (
bill_no, bill_name, customer_id, customer_code, customer_name, bill_period_start, bill_period_end,
+75
View File
@@ -0,0 +1,75 @@
-- =====================================================================
-- 客户余额核销应收账单 V1.0 2026-09-16
-- 1) 余额核销单表 ar_balance_writeoff(资金来源:customer_account.balance)
-- 2) 存量账单未核销金额重算 customer_account.used_credit 占用
-- 3) 菜单与授权:客户账单(2435) / 账单详情(2436) / 余额核销(2437) / 反核销(2438)
-- =====================================================================
-- 余额核销单:用客户账户余额核销应收账单(与 ar_receipt_writeoff 收款核销并列,
-- 两者共享 ar_bill.written_off_amount / unwritten_amount 缓存字段)
CREATE TABLE IF NOT EXISTS ar_balance_writeoff (
writeoff_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '余额核销ID',
writeoff_no VARCHAR(50) NOT NULL COMMENT '核销单号(BWO开头)',
ar_bill_id BIGINT(20) NOT NULL COMMENT '应收账单ID',
bill_no VARCHAR(50) NOT NULL COMMENT '账单号快照',
customer_id BIGINT(20) NOT NULL COMMENT '客户ID',
account_id BIGINT(20) DEFAULT NULL COMMENT '客户账户ID',
currency VARCHAR(10) NOT NULL DEFAULT 'CNY' COMMENT '币种',
writeoff_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '本次核销金额',
balance_flow_id BIGINT(20) DEFAULT NULL COMMENT '核销扣余额流水ID',
reverse_flow_id BIGINT(20) DEFAULT NULL COMMENT '反核销回补余额流水ID',
writeoff_date DATE NOT NULL COMMENT '核销日期',
status VARCHAR(30) NOT NULL COMMENT 'EFFECTIVE/CANCELLED',
remark VARCHAR(500) DEFAULT NULL COMMENT '核销备注',
create_by VARCHAR(64) NOT NULL COMMENT '创建人',
create_time DATETIME NOT NULL COMMENT '创建时间',
cancel_by VARCHAR(64) DEFAULT NULL COMMENT '反核销人',
cancel_time DATETIME DEFAULT NULL COMMENT '反核销时间',
cancel_remark VARCHAR(500) DEFAULT NULL COMMENT '反核销原因',
PRIMARY KEY (writeoff_id),
UNIQUE KEY uk_ar_balance_writeoff_no (writeoff_no),
KEY idx_abw_bill (ar_bill_id, status),
KEY idx_abw_customer (customer_id, status),
KEY idx_abw_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单余额核销表';
-- 存量数据:为仅有账单、没有账户的客户补建账户(理论上CNY账户均已自动开户)
INSERT INTO customer_account (customer_id, currency, balance, frozen_balance, total_credit, extra_credit, used_credit, tax, version, create_by, create_time, del_flag)
SELECT b.customer_id, b.currency, 0, 0, 0, 0, 0, 0, 0, 'system', NOW(), '0'
FROM (
SELECT customer_id, currency FROM ar_bill
WHERE status IN ('CONFIRMED','PARTIAL_WRITTEN_OFF')
GROUP BY customer_id, currency
) b
LEFT JOIN customer_account a
ON a.customer_id = b.customer_id
AND a.currency COLLATE utf8mb4_general_ci = b.currency
AND a.del_flag = '0'
WHERE a.id IS NULL;
-- 已用额度 = 该客户该币种 已确认/部分核销账单的未核销金额合计
UPDATE customer_account a
JOIN (
SELECT customer_id, currency, SUM(unwritten_amount) AS used_total
FROM ar_bill
WHERE status IN ('CONFIRMED','PARTIAL_WRITTEN_OFF')
GROUP BY customer_id, currency
) t ON t.customer_id = a.customer_id
AND t.currency COLLATE utf8mb4_general_ci = a.currency
SET a.used_credit = t.used_total,
a.version = a.version + 1,
a.update_by = 'system',
a.update_time = NOW()
WHERE a.del_flag = '0';
-- 菜单(父菜单 2309 = 客户/供应商管理;如环境ID不同请自行调整)
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES
(2435, '客户账单', 2309, 3, 'customerbill', 'customersuppliermanagement/customerinfo/customerbill/index', '', 1, 0, 'C', '0', '0', 'customer:bill:list', 'documentation', 'admin', NOW(), '客户应收账单只读查询'),
(2436, '账单详情', 2435, 1, '', NULL, '', 1, 0, 'F', '0', '0', 'customer:bill:query', '#', 'admin', NOW(), ''),
(2437, '余额核销', 2435, 2, '', NULL, '', 1, 0, 'F', '0', '0', 'customer:bill:writeoff', '#', 'admin', NOW(), '用客户账户余额核销应收账单'),
(2438, '取消余额核销', 2435, 3, '', NULL, '', 1, 0, 'F', '0', '0', 'customer:bill:writeoff:cancel', '#', 'admin', NOW(), '反核销');
-- 普通角色授权(按实际角色调整)
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(2, 2435), (2, 2436), (2, 2437), (2, 2438);