From 95df65b4f6366e910c4a633ccbf06da6a183d1cd Mon Sep 17 00:00:00 2001
From: wuweihua <1583622118@qq.com>
Date: Fri, 9 Oct 2026 10:09:50 +0800
Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E8=AE=B0=E6=94=B6=E6=AC=BE=E9=80=89?=
=?UTF-8?q?=E6=8B=A9=E8=B4=A6=E5=8D=95=E7=9B=B4=E6=8E=A5=E6=A0=B8=E9=94=80?=
=?UTF-8?q?=EF=BC=8C=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E6=8B=96=E5=8A=A8=E4=B8=8A=E4=BC=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/bms/ar/domain/ArBalanceWriteoff.java | 3 +
.../impl/ArBalanceWriteoffServiceImpl.java | 71 +++++
.../api/BalanceChangeCommand.java | 6 +-
.../api/ICustomerAccountApi.java | 11 +-
.../customermoney/domain/CustomerReceipt.java | 3 +
.../domain/ReceiptWriteoffPlan.java | 82 ++++++
.../mapper/CustomerMoneyMapper.java | 3 +
.../mapper/ReceiptWriteoffPlanMapper.java | 36 +++
.../impl/CustomerMoneyServiceImpl.java | 188 +++++++++++-
.../mapper/ar/ArBalanceWriteoffMapper.xml | 7 +-
.../mapper/customer/CustomerMoneyMapper.xml | 11 +
.../customer/ReceiptWriteoffPlanMapper.xml | 89 ++++++
.../20261008_add_receipt_writeoff_plan.sql | 38 +++
.../customerinfo/customermoney/index.vue | 272 +++++++++++++++++-
14 files changed, 800 insertions(+), 20 deletions(-)
create mode 100644 bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/ReceiptWriteoffPlan.java
create mode 100644 bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/ReceiptWriteoffPlanMapper.java
create mode 100644 bms-back/bms-system/src/main/resources/mapper/customer/ReceiptWriteoffPlanMapper.xml
create mode 100644 bms-back/sql/20261008_add_receipt_writeoff_plan.sql
diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBalanceWriteoff.java b/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBalanceWriteoff.java
index 576c4625..c433ddd4 100644
--- a/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBalanceWriteoff.java
+++ b/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBalanceWriteoff.java
@@ -37,6 +37,9 @@ public class ArBalanceWriteoff extends BaseEntity
/** 客户ID */
private Long customerId;
+ /** 关联收款单ID(收款审核联动核销时写入,手工余额核销为空) */
+ private Long receiptId;
+
/** 客户账户ID */
private Long accountId;
diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java
index 29f35d73..576026fa 100644
--- a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java
+++ b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBalanceWriteoffServiceImpl.java
@@ -26,6 +26,10 @@ import com.bms.ar.mapper.ArBillMapper;
import com.bms.ar.service.IArBalanceWriteoffService;
import com.bms.ar.service.IArBillLogService;
import com.bms.common.exception.ServiceException;
+import com.bms.customer.customermoney.domain.CustomerReceipt;
+import com.bms.customer.customermoney.domain.ReceiptWriteoffPlan;
+import com.bms.customer.customermoney.mapper.CustomerMoneyMapper;
+import com.bms.customer.customermoney.mapper.ReceiptWriteoffPlanMapper;
import com.bms.common.utils.SecurityUtils;
import com.bms.common.utils.uuid.IdUtils;
@@ -49,6 +53,12 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
@Autowired
private IArBillLogService logService;
+ @Autowired
+ private ReceiptWriteoffPlanMapper receiptWriteoffPlanMapper;
+
+ @Autowired
+ private CustomerMoneyMapper customerMoneyMapper;
+
/**
* 按客户查询允许余额核销的未结清账单。
*/
@@ -117,6 +127,7 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
writeoff.setArBillId(bill.getArBillId());
writeoff.setBillNo(bill.getBillNo());
writeoff.setCustomerId(bill.getCustomerId());
+ writeoff.setReceiptId(cmd.getReceiptId());
writeoff.setCurrency(currency);
writeoff.setWriteoffAmount(amount);
writeoff.setWriteoffDate(writeoffDate);
@@ -191,6 +202,66 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
recordLog(bill.getArBillId(), writeoffId, ArLogOperationType.CANCEL_BALANCE_WRITEOFF,
"反核销取消核销:" + cancelRemark,
fromStatus, nextStatus, operator, requestId);
+
+ // 4. 若该核销单由收款审核联动产生(receipt_id 非空):计划置 REVERSED、
+ // 收款单已分配CNY回退为未分配;全程不回补余额、不动额度(收款审核时已全额入账)。
+ if (writeoff.getReceiptId() != null) {
+ reverseReceiptLink(writeoff, operator, cancelRemark);
+ }
+ }
+
+ /**
+ * 反核销收款联动核销单时的单据回写(与反核销同一事务):
+ * 计划 EFFECTIVE -> REVERSED;收款单 allocated_amount 减回、unallocated_amount 回补并重算类型。
+ * 不触碰客户余额与授信额度。
+ */
+ private void reverseReceiptLink(ArBalanceWriteoff writeoff, String operator, String cancelRemark) {
+ ReceiptWriteoffPlan plan = receiptWriteoffPlanMapper
+ .selectEffectiveByWriteoffId(writeoff.getWriteoffId());
+ if (plan == null) {
+ // 无 EFFECTIVE 计划:手工余额核销(无计划)或计划已被处理,不做收款单联动
+ return;
+ }
+ if (receiptWriteoffPlanMapper.updateReversedByWriteoffId(writeoff.getWriteoffId(), operator) != 1) {
+ throw new ServiceException("收款核销计划状态已变化,请刷新后重试");
+ }
+
+ CustomerReceipt receipt = customerMoneyMapper.selectReceiptById(writeoff.getReceiptId());
+ if (receipt == null || !CustomerReceipt.STATUS_CONFIRMED.equals(receipt.getStatus())) {
+ throw new ServiceException("关联收款单不存在或不是已确认状态,无法联动反核销");
+ }
+ BigDecimal amount = nvl(writeoff.getWriteoffAmount());
+ BigDecimal base = nvl(receipt.getBaseAmount());
+ BigDecimal newAllocated = nvl(receipt.getAllocatedAmount()).subtract(amount);
+ if (newAllocated.compareTo(BigDecimal.ZERO) < 0) {
+ newAllocated = BigDecimal.ZERO;
+ }
+ BigDecimal newUnallocated = base.subtract(newAllocated);
+ if (newUnallocated.compareTo(BigDecimal.ZERO) < 0) {
+ newUnallocated = BigDecimal.ZERO;
+ }
+ String receiptType;
+ if (newAllocated.compareTo(BigDecimal.ZERO) == 0) {
+ receiptType = CustomerReceipt.TYPE_PREPAY;
+ } else if (newAllocated.compareTo(base) >= 0) {
+ receiptType = CustomerReceipt.TYPE_REPAY;
+ } else {
+ receiptType = CustomerReceipt.TYPE_MIXED;
+ }
+
+ CustomerReceipt update = new CustomerReceipt();
+ update.setId(receipt.getId());
+ update.setAllocatedAmount(newAllocated);
+ update.setUnallocatedAmount(newUnallocated);
+ update.setReceiptType(receiptType);
+ update.setUpdateBy(operator);
+ if (customerMoneyMapper.updateReceiptAllocation(update) != 1) {
+ throw new ServiceException("关联收款单状态已变化,请刷新后重试");
+ }
+ recordLog(plan.getArBillId(), writeoff.getWriteoffId(), ArLogOperationType.CANCEL_BALANCE_WRITEOFF,
+ "收款单联动核销反核销,回退收款单分配金额 " + amount.toPlainString()
+ + " CNY(" + receipt.getReceiptNo() + ",原因:" + cancelRemark + ")",
+ null, null, operator, IdUtils.fastSimpleUUID());
}
/**
diff --git a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/BalanceChangeCommand.java b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/BalanceChangeCommand.java
index e34f56a8..81ce94e4 100644
--- a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/BalanceChangeCommand.java
+++ b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/BalanceChangeCommand.java
@@ -8,10 +8,12 @@ import lombok.Data;
*
*
典型场景:
*
- * - 余额核销账单/订单扣款 -> {@link ICustomerAccountApi#deductBalance}(余额减少,默认同步释放已用额度)
- * - 收款审核入账/反核销回补/退款 -> {@link ICustomerAccountApi#refundBalance}(余额增加)
+ * - 订单扣款/账户余额支付 -> {@link ICustomerAccountApi#deductBalance}(余额减少)
+ * - 收款审核入账/退款 -> {@link ICustomerAccountApi#refundBalance}(余额增加)
*
*
+ * 账单余额核销/反核销不使用本命令、不改动余额与额度。
+ *
* @author bms
* @date 2026-09-17
*/
diff --git a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/ICustomerAccountApi.java b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/ICustomerAccountApi.java
index a5c2fbf7..f3b79bfd 100644
--- a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/ICustomerAccountApi.java
+++ b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/api/ICustomerAccountApi.java
@@ -14,12 +14,15 @@ import com.bms.customer.customermoney.vo.CustomerAccountStatusVO;
* 调用方自身的事务会与本操作共享事务(默认 REQUIRED),任一失败整体回滚。
*
*
- * - 单据形成应收/计费开单:{@link #occupyCredit} 占用授信(used_credit 增加)
- * - 单据作废/计费核减:{@link #releaseCredit} 释放授信(used_credit 减少)
- * - 用账户余额支付/核销:{@link #deductBalance} 扣余额(可同步释放已用额度)
- * - 收款入账/退款/反核销回补:{@link #refundBalance} 加余额(可同步重新占用)
+ * - 单据形成应收/计费开单:{@link #occupyCredit}(计费口径下为扣减账户余额,允许负余额欠费)
+ * - 单据作废/计费核减:{@link #releaseCredit}(计费口径下为回补账户余额)
+ * - 用账户余额支付:{@link #deductBalance} 扣余额
+ * - 收款审核入账/退款:{@link #refundBalance} 加余额
*
*
+ * 注意(核销口径):应收账单的余额核销与反核销(含收款审核联动核销)不调用本接口,
+ * 只更新账单已核销/未核销金额与状态,客户余额与授信额度均不发生变动。
+ *
* @author bms
* @date 2026-09-17
*/
diff --git a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/CustomerReceipt.java b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/CustomerReceipt.java
index f21b8496..8f6012a7 100644
--- a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/CustomerReceipt.java
+++ b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/CustomerReceipt.java
@@ -125,6 +125,9 @@ public class CustomerReceipt extends BaseEntity
/** 收款附件列表(存 ctr_customer_attachment,attachment_type='3',biz_id 关联本单) */
private List attachmentList;
+ /** 核销账单计划(登记时选择,审核通过后一并核销) */
+ private List writeoffPlans;
+
/** 收款附件数量(列表查询统计) */
private Integer attachmentCount;
}
diff --git a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/ReceiptWriteoffPlan.java b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/ReceiptWriteoffPlan.java
new file mode 100644
index 00000000..5dd38c43
--- /dev/null
+++ b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/domain/ReceiptWriteoffPlan.java
@@ -0,0 +1,82 @@
+package com.bms.customer.customermoney.domain;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.bms.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 收款单核销账单计划 ctr_receipt_writeoff_plan
+ *
+ * 登记收款时指定待核销的应收账单(PENDING);收款审核通过后在同一事务内逐张执行核销,
+ * 回写核销单ID并置 EFFECTIVE;待审核收款单被取消则置 CANCELLED;
+ * 已生效的核销单后续在“余额核销”页被反核销则置 REVERSED(核销/反核销均不改动客户余额与额度)。
+ *
+ * @author bms
+ * @date 2026-10-08
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ReceiptWriteoffPlan extends BaseEntity
+{
+ private static final long serialVersionUID = 1L;
+
+ /** 状态:待执行(收款单待审核) */
+ public static final String STATUS_PENDING = "PENDING";
+ /** 状态:已核销(收款审核通过并完成账单核销) */
+ public static final String STATUS_EFFECTIVE = "EFFECTIVE";
+ /** 状态:已作废(待审核收款单被取消,计划未执行) */
+ public static final String STATUS_CANCELLED = "CANCELLED";
+ /** 状态:已反核销(审核联动核销后,核销单在余额核销页被反核销) */
+ public static final String STATUS_REVERSED = "REVERSED";
+
+ /** 主键 */
+ private Long planId;
+
+ /** 收款单ID */
+ private Long receiptId;
+
+ /** 应收账单ID */
+ private Long arBillId;
+
+ /** 账单号快照 */
+ private String billNo;
+
+ /** 客户ID */
+ private Long customerId;
+
+ /** 账单币种(当前仅支持CNY) */
+ private String currency;
+
+ /** 本次核销金额(本位币CNY) */
+ private BigDecimal writeoffAmount;
+
+ /** 状态:PENDING/EFFECTIVE/CANCELLED/REVERSED */
+ private String status;
+
+ /** 审核后生成的余额核销单ID */
+ private Long writeoffId;
+
+ /** 核销日期(执行后展示) */
+ @JsonFormat(pattern = "yyyy-MM-dd")
+ private Date writeoffDate;
+
+ /* ===== 关联展示字段(来自 ar_bill) ===== */
+
+ /** 账单名称 */
+ private String billName;
+
+ /** 账单应收总额 */
+ private BigDecimal totalAmount;
+
+ /** 账单已核销金额 */
+ private BigDecimal writtenOffAmount;
+
+ /** 账单未核销金额 */
+ private BigDecimal unwrittenAmount;
+
+ /** 账单状态 */
+ private String billStatus;
+}
diff --git a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/CustomerMoneyMapper.java b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/CustomerMoneyMapper.java
index 46f1c80c..0c0185d3 100644
--- a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/CustomerMoneyMapper.java
+++ b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/CustomerMoneyMapper.java
@@ -68,6 +68,9 @@ public interface CustomerMoneyMapper
/** 取消收款单:PENDING -> CANCELLED */
int cancelReceipt(@Param("id") Long id, @Param("updateBy") String updateBy);
+ /** 反核销联动:回退已确认收款单的已分配金额、回补未分配金额并重算收款类型(不动余额) */
+ int updateReceiptAllocation(CustomerReceipt receipt);
+
/* ================= 余额流水 ================= */
/** 查询流水列表 */
diff --git a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/ReceiptWriteoffPlanMapper.java b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/ReceiptWriteoffPlanMapper.java
new file mode 100644
index 00000000..86395941
--- /dev/null
+++ b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/mapper/ReceiptWriteoffPlanMapper.java
@@ -0,0 +1,36 @@
+package com.bms.customer.customermoney.mapper;
+
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+import com.bms.customer.customermoney.domain.ReceiptWriteoffPlan;
+
+/**
+ * 收款单核销账单计划 Mapper
+ *
+ * @author bms
+ * @date 2026-10-08
+ */
+public interface ReceiptWriteoffPlanMapper
+{
+ /** 新增一条核销计划 */
+ int insertPlan(ReceiptWriteoffPlan plan);
+
+ /** 按收款单查询全部计划(关联账单展示字段,按计划ID升序) */
+ List selectByReceiptId(@Param("receiptId") Long receiptId);
+
+ /** 执行成功回写:PENDING -> EFFECTIVE,记录核销单ID与核销日期 */
+ int updateEffective(@Param("planId") Long planId,
+ @Param("writeoffId") Long writeoffId,
+ @Param("updateBy") String updateBy);
+
+ /** 收款单取消:其全部 PENDING 计划置 CANCELLED */
+ int cancelPendingByReceiptId(@Param("receiptId") Long receiptId,
+ @Param("updateBy") String updateBy);
+
+ /** 按核销单ID查询 EFFECTIVE 计划(反核销联动用) */
+ ReceiptWriteoffPlan selectEffectiveByWriteoffId(@Param("writeoffId") Long writeoffId);
+
+ /** 反核销联动:EFFECTIVE -> REVERSED */
+ int updateReversedByWriteoffId(@Param("writeoffId") Long writeoffId,
+ @Param("updateBy") String updateBy);
+}
diff --git a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java
index 39636f2d..9b2b7b77 100644
--- a/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java
+++ b/bms-back/bms-system/src/main/java/com/bms/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java
@@ -5,8 +5,11 @@ import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
import java.util.Date;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -15,8 +18,13 @@ import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.SecurityUtils;
+import com.bms.ar.domain.ArBalanceWriteoff;
import com.bms.ar.domain.ArBankAccount;
+import com.bms.ar.domain.ArBill;
+import com.bms.ar.enums.ArBillStatus;
import com.bms.ar.mapper.ArBankAccountMapper;
+import com.bms.ar.mapper.ArBillMapper;
+import com.bms.ar.service.IArBalanceWriteoffService;
import com.bms.customer.customerinfo.domain.BmsCustomer;
import com.bms.customer.customerinfo.domain.BmsCustomerAttachment;
import com.bms.customer.customerinfo.mapper.BmsCustomerMapper;
@@ -28,8 +36,10 @@ import com.bms.customer.customermoney.domain.CustomerBalanceFlow;
import com.bms.customer.customermoney.domain.CustomerCreditApply;
import com.bms.customer.customermoney.domain.CustomerCreditFlow;
import com.bms.customer.customermoney.domain.CustomerReceipt;
+import com.bms.customer.customermoney.domain.ReceiptWriteoffPlan;
import com.bms.customer.customermoney.mapper.CustomerCreditApplyMapper;
import com.bms.customer.customermoney.mapper.CustomerMoneyMapper;
+import com.bms.customer.customermoney.mapper.ReceiptWriteoffPlanMapper;
import com.bms.customer.customermoney.service.ICreditQuotaService;
import com.bms.customer.customermoney.service.ICustomerMoneyService;
import com.bms.price.service.IPrcOrderBillService;
@@ -79,6 +89,18 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
@Autowired
private ArBankAccountMapper arBankAccountMapper;
+ /** 应收账单:登记收款选择核销账单时校验账单状态/未核销额 */
+ @Autowired
+ private ArBillMapper arBillMapper;
+
+ /** 收款核销计划 */
+ @Autowired
+ private ReceiptWriteoffPlanMapper receiptWriteoffPlanMapper;
+
+ /** 余额核销:收款审核通过事务内逐张核销账单 */
+ @Autowired
+ private IArBalanceWriteoffService balanceWriteoffService;
+
@Override
@Transactional(rollbackFor = Exception.class)
public List selectAccountList(Long customerId)
@@ -118,6 +140,7 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
query.setBizType(BmsCustomerAttachment.BIZ_TYPE_CUSTOMER_RECEIPT);
query.setBizId(id);
receipt.setAttachmentList(bmsCustomerMapper.selectBmsCustomerAttachmentList(query));
+ receipt.setWriteoffPlans(receiptWriteoffPlanMapper.selectByReceiptId(id));
}
return receipt;
}
@@ -134,6 +157,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
{
throw new ServiceException("收款金额必须大于0");
}
+ // 统一金额口径:入口即规范为两位小数,后续本位币、核销合计、余额分配均基于该值计算
+ receipt.setAmount(receipt.getAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
if (!StringUtils.hasText(receipt.getReceiptType()))
{
receipt.setReceiptType(CustomerReceipt.TYPE_PREPAY);
@@ -231,11 +256,24 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
BigDecimal baseAmount = receipt.getAmount().multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
receipt.setBaseAmount(baseAmount);
+ // 核销账单计划校验:当前账单均为人民币(CNY)账单,计划金额即本位币 CNY 金额,
+ // 上限为收款按汇率折算后的本位币金额 baseAmount;核销本身不增减客户余额。
+ List plans = validateWriteoffPlans(receipt);
+ BigDecimal allocatedBase = plans.stream()
+ .map(ReceiptWriteoffPlan::getWriteoffAmount)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ // 全额分配=还款核销,部分分配=混合,不选账单=预充值/保持所选类型(本位币口径比较)
+ if (!plans.isEmpty())
+ {
+ receipt.setReceiptType(allocatedBase.compareTo(baseAmount) == 0
+ ? CustomerReceipt.TYPE_REPAY : CustomerReceipt.TYPE_MIXED);
+ }
+
receipt.setReceiptNo(generateNo("SK"));
receipt.setStatus(CustomerReceipt.STATUS_PENDING);
- receipt.setAllocatedAmount(BigDecimal.ZERO);
- // 核销口径统一为本位币 CNY
- receipt.setUnallocatedAmount(baseAmount);
+ // 核销金额即本位币 CNY,无需再乘汇率
+ receipt.setAllocatedAmount(allocatedBase);
+ receipt.setUnallocatedAmount(baseAmount.subtract(allocatedBase));
if (!StringUtils.hasText(receipt.getInvoiceStatus()))
{
receipt.setInvoiceStatus("NONE");
@@ -243,11 +281,146 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
receipt.setVersion(0);
customerMoneyMapper.insertReceipt(receipt);
+ // 核销计划落 PENDING,审核通过事务内再逐张执行
+ String operator = receipt.getCreateBy();
+ for (ReceiptWriteoffPlan plan : plans)
+ {
+ plan.setReceiptId(receipt.getId());
+ plan.setStatus(ReceiptWriteoffPlan.STATUS_PENDING);
+ plan.setCreateBy(operator);
+ receiptWriteoffPlanMapper.insertPlan(plan);
+ }
+
// 收款附件:复用客户附件表,业务模块挂 customer_receipt,biz_id 关联收款单
saveReceiptAttachments(receipt);
return 1;
}
+ /**
+ * 校验登记收款携带的核销账单计划并规范化。
+ * 口径(暂不支持多币种账单):
+ * 账单必须属于当前客户、账单币种为人民币 CNY、状态可核销(已确认/部分核销);
+ * 每张本次核销额(CNY)大于0且不超过账单未核销额;
+ * 合计不超过收款按汇率折算后的本位币金额 baseAmount(CNY)。
+ * 审核时会再次锁账单强校验,防止登记后账单被别处抢先核销。
+ */
+ private List validateWriteoffPlans(CustomerReceipt receipt)
+ {
+ List raw = receipt.getWriteoffPlans();
+ if (CollectionUtils.isEmpty(raw))
+ {
+ return new ArrayList<>();
+ }
+ BigDecimal baseAmount = receipt.getBaseAmount() != null
+ ? receipt.getBaseAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)
+ : receipt.getAmount().multiply(receipt.getExchangeRate()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
+ List plans = new ArrayList<>();
+ Set billIds = new HashSet<>();
+ BigDecimal total = BigDecimal.ZERO;
+ for (ReceiptWriteoffPlan item : raw)
+ {
+ if (item == null || item.getArBillId() == null)
+ {
+ continue;
+ }
+ BigDecimal amount = item.getWriteoffAmount();
+ if (amount == null)
+ {
+ throw new ServiceException("请填写每张账单的本次核销金额");
+ }
+ amount = amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
+ if (amount.compareTo(BigDecimal.ZERO) <= 0)
+ {
+ throw new ServiceException("核销金额必须大于0");
+ }
+ if (!billIds.add(item.getArBillId()))
+ {
+ throw new ServiceException("同一账单不能重复选择,请合并为一条并填写合计金额");
+ }
+ ArBill bill = arBillMapper.selectArBillById(item.getArBillId());
+ if (bill == null)
+ {
+ throw new ServiceException("所选应收账单不存在,请刷新后重新选择");
+ }
+ if (!receipt.getCustomerId().equals(bill.getCustomerId()))
+ {
+ throw new ServiceException("账单【" + bill.getBillNo() + "】不属于当前客户");
+ }
+ // 现阶段仅支持人民币账单核销;外币账单暂不在此入口处理
+ String billCurrency = StringUtils.hasText(bill.getCurrency())
+ ? bill.getCurrency().trim().toUpperCase() : BASE_CURRENCY;
+ if (!BASE_CURRENCY.equals(billCurrency))
+ {
+ throw new ServiceException("账单【" + bill.getBillNo() + "】币种为 " + billCurrency
+ + ",当前仅支持人民币(CNY)账单核销");
+ }
+ ArBillStatus billStatus = ArBillStatus.of(bill.getStatus());
+ if (billStatus == null || !billStatus.canWriteOff())
+ {
+ throw new ServiceException("账单【" + bill.getBillNo() + "】当前状态不允许核销:"
+ + (billStatus == null ? bill.getStatus() : billStatus.getLabel()));
+ }
+ BigDecimal unwritten = bill.getUnwrittenAmount() == null
+ ? BigDecimal.ZERO : bill.getUnwrittenAmount();
+ if (amount.compareTo(unwritten) > 0)
+ {
+ throw new ServiceException("账单【" + bill.getBillNo() + "】本次核销金额 "
+ + amount.toPlainString() + " 超过其未核销金额 " + unwritten.toPlainString());
+ }
+ total = total.add(amount);
+ if (total.compareTo(baseAmount) > 0)
+ {
+ throw new ServiceException("核销金额合计 " + total.toPlainString()
+ + " CNY 超过本收款单折算本位币金额 " + baseAmount.toPlainString() + " CNY");
+ }
+
+ ReceiptWriteoffPlan plan = new ReceiptWriteoffPlan();
+ plan.setArBillId(bill.getArBillId());
+ plan.setBillNo(bill.getBillNo());
+ plan.setCustomerId(bill.getCustomerId());
+ plan.setCurrency(BASE_CURRENCY);
+ plan.setWriteoffAmount(amount);
+ plan.setRemark(StringUtils.hasText(item.getRemark()) ? item.getRemark().trim() : null);
+ plans.add(plan);
+ }
+ return plans;
+ }
+
+ /**
+ * 收款审核通过后执行核销计划:同一事务内逐张锁账单核销,
+ * 任一账单已被别处抢先核销导致超额时,整笔审核回滚(含余额入账),不产生半截数据。
+ */
+ private void executeReceiptWriteoffPlans(CustomerReceipt receipt, String operator)
+ {
+ List plans = receiptWriteoffPlanMapper.selectByReceiptId(receipt.getId());
+ if (CollectionUtils.isEmpty(plans))
+ {
+ return;
+ }
+ Date writeoffDate = new Date();
+ for (ReceiptWriteoffPlan plan : plans)
+ {
+ if (!ReceiptWriteoffPlan.STATUS_PENDING.equals(plan.getStatus()))
+ {
+ continue;
+ }
+ ArBalanceWriteoff cmd = new ArBalanceWriteoff();
+ cmd.setArBillId(plan.getArBillId());
+ cmd.setCustomerId(plan.getCustomerId());
+ cmd.setReceiptId(receipt.getId());
+ cmd.setWriteoffAmount(plan.getWriteoffAmount());
+ cmd.setWriteoffDate(writeoffDate);
+ cmd.setRemark("收款单审核联动核销:" + receipt.getReceiptNo());
+ cmd.setCreateBy(operator);
+ // writeoff 内部锁账单行、复核状态与未核销额,冲突时抛异常由外层事务统一回滚
+ ArBalanceWriteoff done = balanceWriteoffService.writeoff(cmd);
+ if (receiptWriteoffPlanMapper.updateEffective(plan.getPlanId(), done.getWriteoffId(), operator) != 1)
+ {
+ throw new ServiceException("收款核销计划状态已变化,请刷新后重试");
+ }
+ }
+ }
+
/**
* 构造汇率表来源说明:汇率来源(中国银行/央行等)+ 命中路径(正向/反向取倒数/经CNY交叉)。
* resolveRate 返回对象的 remark 带 __RATE_PATH__ 前缀标记命中路径。
@@ -343,6 +516,9 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
{
throw new ServiceException("收款单状态已变更,请刷新后重试");
}
+
+ // 3. 登记时选择的账单在同一事务内一并核销;任一账单核销失败则整笔审核回滚
+ executeReceiptWriteoffPlans(receipt, username);
return receiptRows;
}
@@ -359,7 +535,11 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService
{
throw new ServiceException("仅待确认的收款单可以取消");
}
- return customerMoneyMapper.cancelReceipt(id, SecurityUtils.getUsername());
+ String operator = SecurityUtils.getUsername();
+ int rows = customerMoneyMapper.cancelReceipt(id, operator);
+ // 尚未执行的核销计划一并作废
+ receiptWriteoffPlanMapper.cancelPendingByReceiptId(id, operator);
+ return rows;
}
@Override
diff --git a/bms-back/bms-system/src/main/resources/mapper/ar/ArBalanceWriteoffMapper.xml b/bms-back/bms-system/src/main/resources/mapper/ar/ArBalanceWriteoffMapper.xml
index 6aff9eda..f13a6fb6 100644
--- a/bms-back/bms-system/src/main/resources/mapper/ar/ArBalanceWriteoffMapper.xml
+++ b/bms-back/bms-system/src/main/resources/mapper/ar/ArBalanceWriteoffMapper.xml
@@ -10,6 +10,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+
@@ -28,7 +29,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
- select w.writeoff_id, w.writeoff_no, w.ar_bill_id, w.bill_no, w.customer_id, w.account_id,
+ select w.writeoff_id, w.writeoff_no, w.ar_bill_id, w.bill_no, w.customer_id, w.receipt_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,
@@ -40,11 +41,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
insert into ar_balance_writeoff (
- writeoff_no, ar_bill_id, bill_no, customer_id, account_id, currency,
+ writeoff_no, ar_bill_id, bill_no, customer_id, receipt_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},
+ #{writeoffNo}, #{arBillId}, #{billNo}, #{customerId}, #{receiptId}, #{accountId}, #{currency},
#{writeoffAmount}, #{balanceFlowId}, #{reverseFlowId}, #{writeoffDate},
#{status}, #{remark}, #{createBy}, sysdate()
)
diff --git a/bms-back/bms-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml b/bms-back/bms-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml
index 66769861..d26b1cd0 100644
--- a/bms-back/bms-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml
+++ b/bms-back/bms-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml
@@ -392,6 +392,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where id = #{id} and status = 'PENDING' and del_flag = '0'
+
+
+ update ctr_customer_receipt
+ set allocated_amount = #{allocatedAmount},
+ unallocated_amount = #{unallocatedAmount},
+ receipt_type = #{receiptType},
+ update_by = #{updateBy},
+ update_time = now()
+ where id = #{id} and status = 'CONFIRMED' and del_flag = '0'
+
+
diff --git a/bms-back/bms-system/src/main/resources/mapper/customer/ReceiptWriteoffPlanMapper.xml b/bms-back/bms-system/src/main/resources/mapper/customer/ReceiptWriteoffPlanMapper.xml
new file mode 100644
index 00000000..35365f10
--- /dev/null
+++ b/bms-back/bms-system/src/main/resources/mapper/customer/ReceiptWriteoffPlanMapper.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ select p.plan_id, p.receipt_id, p.ar_bill_id, p.bill_no, p.customer_id, p.currency,
+ p.writeoff_amount, p.status, p.writeoff_id, p.remark,
+ p.create_by, p.create_time, p.update_by, p.update_time,
+ w.writeoff_date,
+ b.bill_name, b.total_amount, b.written_off_amount, b.unwritten_amount,
+ b.status as bill_status
+ from ctr_receipt_writeoff_plan p
+ left join ar_balance_writeoff w on w.writeoff_id = p.writeoff_id
+ left join ar_bill b on b.ar_bill_id = p.ar_bill_id
+
+
+
+ insert into ctr_receipt_writeoff_plan (
+ receipt_id, ar_bill_id, bill_no, customer_id, currency, writeoff_amount,
+ status, writeoff_id, remark, create_by, create_time, del_flag
+ ) values (
+ #{receiptId}, #{arBillId}, #{billNo}, #{customerId}, #{currency}, #{writeoffAmount},
+ #{status}, #{writeoffId}, #{remark}, #{createBy}, sysdate(), '0'
+ )
+
+
+
+
+
+ update ctr_receipt_writeoff_plan
+ set status = 'EFFECTIVE',
+ writeoff_id = #{writeoffId},
+ update_by = #{updateBy},
+ update_time = sysdate()
+ where plan_id = #{planId} and status = 'PENDING' and del_flag = '0'
+
+
+
+ update ctr_receipt_writeoff_plan
+ set status = 'CANCELLED',
+ update_by = #{updateBy},
+ update_time = sysdate()
+ where receipt_id = #{receiptId} and status = 'PENDING' and del_flag = '0'
+
+
+
+
+
+ update ctr_receipt_writeoff_plan
+ set status = 'REVERSED',
+ update_by = #{updateBy},
+ update_time = sysdate()
+ where writeoff_id = #{writeoffId} and status = 'EFFECTIVE' and del_flag = '0'
+
+
diff --git a/bms-back/sql/20261008_add_receipt_writeoff_plan.sql b/bms-back/sql/20261008_add_receipt_writeoff_plan.sql
new file mode 100644
index 00000000..d358e162
--- /dev/null
+++ b/bms-back/sql/20261008_add_receipt_writeoff_plan.sql
@@ -0,0 +1,38 @@
+-- ============================================================
+-- 功能:登记收款时选择核销账单,审核通过后一并核销
+-- 日期:2026-10-08
+-- 说明:
+-- 1) 新增收款核销计划表 ctr_receipt_writeoff_plan:登记时落 PENDING 计划,
+-- 审核通过事务内逐张执行 ar_bill 核销并回写 EFFECTIVE/核销单ID,取消收款单则置 CANCELLED;
+-- 联动核销单后续在余额核销页被反核销则置 REVERSED,并回退收款单 allocated/unallocated(不动余额/额度)。
+-- 2) ar_balance_writeoff 增加 receipt_id:审核联动生成的核销单可溯源到收款单;
+-- 手工余额核销保持为空。
+-- ============================================================
+
+-- 1. 收款核销计划表
+CREATE TABLE IF NOT EXISTS `ctr_receipt_writeoff_plan` (
+ `plan_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '计划ID',
+ `receipt_id` bigint(20) NOT NULL COMMENT '收款单ID(ctr_customer_receipt.id)',
+ `ar_bill_id` bigint(20) NOT NULL COMMENT '应收账单ID',
+ `bill_no` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '账单号快照',
+ `customer_id` bigint(20) NOT NULL COMMENT '客户ID',
+ `currency` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '账单币种(当前仅支持CNY)',
+ `writeoff_amount` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '本次核销金额(本位币CNY;当前仅支持人民币账单)',
+ `status` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待执行/EFFECTIVE已核销/CANCELLED已作废/REVERSED已反核销',
+ `writeoff_id` bigint(20) DEFAULT NULL COMMENT '审核后生成的余额核销单ID(ar_balance_writeoff.writeoff_id)',
+ `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
+ `create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `del_flag` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0' COMMENT '删除标志(0存在 1删除)',
+ PRIMARY KEY (`plan_id`),
+ KEY `idx_crwp_receipt` (`receipt_id`,`status`),
+ KEY `idx_crwp_bill` (`ar_bill_id`),
+ KEY `idx_crwp_customer` (`customer_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收款单核销账单计划';
+
+-- 2. 余额核销单关联收款单
+ALTER TABLE `ar_balance_writeoff`
+ ADD COLUMN `receipt_id` bigint(20) DEFAULT NULL COMMENT '关联收款单ID(收款审核联动核销时写入)' AFTER `customer_id`,
+ ADD KEY `idx_abw_receipt` (`receipt_id`);
diff --git a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue
index 4cf83582..e5a89d01 100644
--- a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue
+++ b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue
@@ -531,7 +531,12 @@
-
+
+
+ {{ scope.row.writeoffNo }}
+ 收款联动
+
+
@@ -574,7 +579,7 @@
-
+
@@ -665,6 +670,54 @@
+
+
+
+ 选择账单核销
+
+ 本次核销合计:{{ formatMoney(receiptWriteoffTotal) }} CNY
+ ,收款折算本位币:{{ formatMoney(receiptBaseCny) }} CNY
+
+
+
+
+
+
+
+
+
+ {{ formatMoney(scope.row.unwrittenAmount) }} {{ scope.row.currency }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 不选账单则整笔计入客户余额,可在“账单核销”页手工核销
+
+
+
+ 当前仅支持人民币(CNY)账单核销,账单行金额均按 CNY 填写;
+ 所选账单在收款单审核通过后一并核销,核销合计不能超过收款金额按汇率折算的本位币金额
+ ({{ formatMoney(receiptBaseCny) }} CNY);核销不扣减客户余额,正常计入客户余额。
+
+
+
{{ viewDialog.data.remark || '-' }}
+
+ 关联核销账单({{ viewDialog.data.writeoffPlans.length }})
+
+
+
+
+ {{ formatMoney(scope.row.writeoffAmount) }} {{ scope.row.currency }}
+
+
+
+
+ {{ receiptPlanStatusLabel(scope.row.status) }}
+
+
+
+
+ {{ scope.row.writeoffDate ? formatDate(scope.row.writeoffDate) : '-' }}
+
+
+
+
收款凭证附件
@@ -913,6 +987,49 @@
+
+
+
+ 当前仅支持人民币(CNY)账单核销,以下为状态已确认/部分核销的 CNY 账单;
+ 已选 {{ billPicker.selected.length }} 张,合计未核销
+ {{ formatMoney(billPickerSelectedTotal) }} CNY,
+ 本收款单折算可核销上限 {{ formatMoney(receiptBaseCny) }} CNY
+
+
+
+
+
+
+ {{ formatDate(scope.row.billDate) }}
+
+
+ {{ formatMoney(scope.row.totalAmount) }} {{ scope.row.currency }}
+
+
+ {{ formatMoney(scope.row.writtenOffAmount) }}
+
+
+ {{ formatMoney(scope.row.unwrittenAmount) }}
+
+
+
+
+
+
+ 确 定({{ billPicker.selected.length }})
+ 取 消
+
+
+
@@ -966,7 +1083,10 @@
反核销后,账单「{{ cancelWoDialog.row.billNo }}」恢复为未核销状态(核销金额 CNY {{ formatMoney(cancelWoDialog.row.writeoffAmount) }});
- 口径变更后不再回补账户余额、不再变动已用额度。
+ 不回补账户余额、不变动已用额度。
+
+
该核销由收款单审核联动产生,反核销后收款单中本笔将回到“未分配”金额,可重新核销其他账单,已入账余额不受影响。
+
@@ -1386,12 +1506,13 @@ function handleExportCreditFlow() {
/** 审核通过 */
function handleConfirmReceipt(row) {
- proxy.$modal.confirm(`是否确认审核通过收款单「${row.receiptNo}」?审核通过后金额将计入客户余额。`).then(() => {
+ proxy.$modal.confirm(`是否确认审核通过收款单「${row.receiptNo}」?审核通过后金额计入客户余额,登记时选择的账单将一并核销。`).then(() => {
return confirmReceipt(row.id)
}).then(() => {
- proxy.$modal.msgSuccess("审核通过,余额已更新")
+ proxy.$modal.msgSuccess("审核通过,余额与账单核销已更新")
getReceiptList()
loadAccounts()
+ loadBillWriteoffData()
if (activeTab.value === 'flow') getFlowList()
}).catch(() => {})
}
@@ -1434,7 +1555,8 @@ function resetReceiptForm() {
bankAccountNo: '',
bankTransactionNo: '',
remark: '',
- attachmentList: []
+ attachmentList: [],
+ writeoffPlans: []
}
proxy.resetForm('receiptFormRef')
}
@@ -1476,7 +1598,7 @@ function fetchDefaultRate() {
})
}
-/** 切换币种:重置手工标记并重新取默认汇率 */
+/** 切换币种:重置手工标记并重新取默认汇率;核销账单固定为CNY,只需按新汇率重算可核销上限,不清空已选 */
function handleCurrencyChange() {
rateEdited.value = false
rateSourceInfo.value = { source: '', ratePath: '' }
@@ -1502,6 +1624,130 @@ watch(() => receiptDialog.form.receivedAt, () => {
}
})
+/* ============ 登记收款-选择核销账单 ============ */
+const billPickerTableRef = ref(null)
+const billPicker = reactive({
+ visible: false,
+ loading: false,
+ rows: [],
+ selected: []
+})
+
+/** 收款金额按汇率折算的本位币金额(CNY),即核销合计上限 */
+const receiptBaseCny = computed(() => {
+ const amt = Number(receiptDialog.form.amount) || 0
+ const rate = Number(receiptDialog.form.exchangeRate) || 0
+ return Math.round(amt * rate * 100) / 100
+})
+/** 已分配合计(账单均为CNY,此值即 CNY) */
+const receiptWriteoffTotal = computed(() => {
+ const list = receiptDialog.form.writeoffPlans || []
+ return list.reduce((s, r) => s + (Number(r.writeoffAmount) || 0), 0)
+})
+/** 核销后剩余入账金额(折算CNY - 已分配CNY) */
+const receiptWriteoffRemain = computed(() => {
+ return Math.round((receiptBaseCny.value - receiptWriteoffTotal.value) * 100) / 100
+})
+/** 选择器中勾选账单的未核销额合计(仅提示) */
+const billPickerSelectedTotal = computed(() => {
+ return billPicker.selected.reduce((s, r) => s + (Number(r.unwrittenAmount) || 0), 0)
+})
+
+const RECEIPT_PLAN_STATUS_MAP = {
+ PENDING: { label: '待核销', type: 'warning' },
+ EFFECTIVE: { label: '已核销', type: 'success' },
+ CANCELLED: { label: '已作废', type: 'info' },
+ REVERSED: { label: '已反核销', type: 'danger' }
+}
+function receiptPlanStatusLabel(s) {
+ return RECEIPT_PLAN_STATUS_MAP[s]?.label || s || '-'
+}
+function receiptPlanStatusTag(s) {
+ return RECEIPT_PLAN_STATUS_MAP[s]?.type || 'info'
+}
+
+/** 打开账单选择器:只列与收款同币种的未核销账单,并回显已选 */
+function openBillPicker() {
+ const customerId = route.query.customerId
+ if (!customerId) {
+ proxy.$modal.msgError('缺少客户信息,请重新进入页面')
+ return
+ }
+ billPicker.visible = true
+ billPicker.loading = true
+ billPicker.rows = []
+ billPicker.selected = []
+ listUnsettledBill(customerId).then(res => {
+ // 现阶段只核销人民币账单,与收款币种无关;核销上限为收款折算 CNY
+ billPicker.rows = (res.data || []).filter(r => (r.currency || 'CNY') === 'CNY')
+ nextTick(() => {
+ billPickerTableRef.value?.clearSelection()
+ const chosen = receiptDialog.form.writeoffPlans || []
+ billPicker.rows.forEach(r => {
+ if (chosen.some(p => p.arBillId === r.arBillId)) {
+ billPickerTableRef.value?.toggleRowSelection(r, true)
+ }
+ })
+ })
+ }).finally(() => {
+ billPicker.loading = false
+ })
+}
+
+function onPickerSelectionChange(rows) {
+ billPicker.selected = rows || []
+}
+
+/** 确认选择:新增行默认核销额=min(账单未核销额CNY, 收款折算CNY剩余可用),保留已填金额 */
+function confirmBillPicker() {
+ const oldMap = new Map((receiptDialog.form.writeoffPlans || []).map(p => [p.arBillId, p]))
+ receiptDialog.form.writeoffPlans = billPicker.selected.map(r => {
+ const existed = oldMap.get(r.arBillId)
+ const unwritten = Number(r.unwrittenAmount) || 0
+ const usedByOthers = receiptDialog.form.writeoffPlans
+ .filter(p => p.arBillId !== r.arBillId)
+ .reduce((s, p) => s + (Number(p.writeoffAmount) || 0), 0)
+ const remainCny = Math.max(receiptBaseCny.value - usedByOthers, 0)
+ return {
+ arBillId: r.arBillId,
+ billNo: r.billNo,
+ billName: r.billName,
+ currency: 'CNY',
+ unwrittenAmount: unwritten,
+ writeoffAmount: existed ? Number(existed.writeoffAmount) : Math.round(Math.min(unwritten, remainCny) * 100) / 100
+ }
+ })
+ billPicker.visible = false
+}
+
+function removeReceiptBill(index) {
+ receiptDialog.form.writeoffPlans.splice(index, 1)
+}
+
+/** 提交前校验核销账单(金额口径均为 CNY) */
+function validateReceiptWriteoffPlans() {
+ const cap = receiptBaseCny.value
+ const list = receiptDialog.form.writeoffPlans || []
+ let total = 0
+ for (const p of list) {
+ const amt = Number(p.writeoffAmount)
+ if (!amt || amt <= 0) {
+ proxy.$modal.msgError(`账单【${p.billNo}】本次核销金额必须大于0`)
+ return false
+ }
+ if (amt > Number(p.unwrittenAmount)) {
+ proxy.$modal.msgError(`账单【${p.billNo}】核销金额超过其未核销金额`)
+ return false
+ }
+ total += amt
+ }
+ if (Math.round(total * 100) > Math.round(cap * 100)) {
+ proxy.$modal.msgError(`核销金额合计 ${total.toFixed(2)} CNY 超过收款折算本位币金额 ${cap.toFixed(2)} CNY`)
+ return false
+ }
+ return true
+}
+
function submitReceipt() {
receiptFormRef.value.validate(valid => {
if (!valid) return
@@ -1511,6 +1757,7 @@ function submitReceipt() {
proxy.$modal.msgError(`请录入 ${cur} 对 CNY 的汇率(汇率表未配置时可手工填写)`)
return
}
+ if (!validateReceiptWriteoffPlans()) return
submitLoading.value = true
addReceipt(receiptDialog.form).then(() => {
proxy.$modal.msgSuccess("登记成功,等待审核")
@@ -2222,6 +2469,17 @@ watch(() => route.query.customerId, (newId, oldId) => {
.writeoff-summary {
margin-bottom: 12px;
}
+.receipt-wo-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+}
+.receipt-wo-summary {
+ font-size: 13px;
+ color: #606266;
+}
.wo-summary {
display: flex;
align-items: center;