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; * *

典型场景:

* * + *

账单余额核销/反核销不使用本命令、不改动余额与额度。

+ * * @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),任一失败整体回滚。

* * * + *

注意(核销口径):应收账单的余额核销与反核销(含收款审核联动核销)不调用本接口, + * 只更新账单已核销/未核销金额与状态,客户余额与授信额度均不发生变动。

+ * * @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 @@ - + + + @@ -574,7 +579,7 @@ - + @@ -665,6 +670,54 @@ + + +
+ 选择账单核销 + + 本次核销合计:{{ formatMoney(receiptWriteoffTotal) }} CNY + ,收款折算本位币:{{ formatMoney(receiptBaseCny) }} CNY + + + + +
+ + + + + + + + + + + + + + +
+ 当前仅支持人民币(CNY)账单核销,账单行金额均按 CNY 填写; + 所选账单在收款单审核通过后一并核销,核销合计不能超过收款金额按汇率折算的本位币金额 + ({{ formatMoney(receiptBaseCny) }} CNY);核销不扣减客户余额,正常计入客户余额。 +
+
+
{{ viewDialog.data.remark || '-' }} + +
收款凭证附件
@@ -913,6 +987,49 @@
+ + + + 当前仅支持人民币(CNY)账单核销,以下为状态已确认/部分核销的 CNY 账单; + 已选 {{ billPicker.selected.length }} 张,合计未核销 + {{ formatMoney(billPickerSelectedTotal) }} CNY, + 本收款单折算可核销上限 {{ formatMoney(receiptBaseCny) }} CNY + + + + + + + + + + + + + + + + + + + + + + @@ -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;