diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java index 14ac307e..77ef332a 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java @@ -22,6 +22,7 @@ import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.domain.vo.ArBillActionRequest; import com.ruoyi.ar.domain.vo.ArBillGenerateQuery; import com.ruoyi.ar.domain.vo.ArBillGenerateRequest; +import com.ruoyi.ar.domain.vo.ArBillQuery; import com.ruoyi.ar.service.IArBillService; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; @@ -43,7 +44,7 @@ public class ArBillController extends BaseController @PreAuthorize("@ss.hasPermi('ar:bill:list')") @GetMapping("/list") - public TableDataInfo list(ArBill query) + public TableDataInfo list(ArBillQuery query) { startPage(); List list = billService.selectArBillList(query); diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customerinfo/BmsCustomerPriceSetController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customerinfo/BmsCustomerPriceSetController.java new file mode 100644 index 00000000..477bbb21 --- /dev/null +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customerinfo/BmsCustomerPriceSetController.java @@ -0,0 +1,78 @@ +package com.ruoyi.web.controller.customer.customerinfo; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.customer.customerinfo.domain.BmsCustomerPriceSet; +import com.ruoyi.customer.customerinfo.domain.BmsPriceSetOption; +import com.ruoyi.customer.customerinfo.service.IBmsCustomerPriceSetService; + +/** + * 客户套餐绑定Controller(客户管理-客户编辑-绑定套餐) + * + * @author ruoyi + * @date 2026-09-16 + */ +@RestController +@RequestMapping("/customersupplier/customerinfo/priceset") +public class BmsCustomerPriceSetController extends BaseController +{ + @Autowired + private IBmsCustomerPriceSetService customerPriceSetService; + + /** + * 查询客户已绑定的套餐列表 + */ + @PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:list')") + @GetMapping("/bind/list") + public AjaxResult bindList(Long customerId) + { + List list = customerPriceSetService.selectBindList(customerId); + return success(list); + } + + /** + * 查询可绑定的生效套餐选项 + */ + @PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:list')") + @GetMapping("/option/list") + public AjaxResult optionList(BmsPriceSetOption query) + { + return success(customerPriceSetService.selectOptionList(query)); + } + + /** + * 保存客户套餐绑定(已绑定则更新/更换套餐,未绑定则新增) + */ + @PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:edit')") + @Log(title = "客户套餐绑定", businessType = BusinessType.INSERT) + @PostMapping("/bind") + public AjaxResult saveBind(@RequestBody BmsCustomerPriceSet bind) + { + bind.setCreateBy(getUsername()); + bind.setUpdateBy(getUsername()); + return toAjax(customerPriceSetService.saveBind(bind)); + } + + /** + * 解绑客户套餐 + */ + @PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:edit')") + @Log(title = "客户套餐解绑", businessType = BusinessType.DELETE) + @DeleteMapping("/bind/{customerPricesetIds}") + public AjaxResult removeBind(@PathVariable Long[] customerPricesetIds) + { + return toAjax(customerPriceSetService.deleteBindByIds(customerPricesetIds)); + } +} diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerBillController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerBillController.java new file mode 100644 index 00000000..708c440a --- /dev/null +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerBillController.java @@ -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.service.IArBalanceWriteoffService; +import com.ruoyi.ar.service.IArBillService; +import com.ruoyi.ar.domain.vo.ArBillQuery; +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 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(ArBillQuery 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)); + } +} diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java index ccbe7aca..26058a3b 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/customer/customermoney/CustomerMoneyController.java @@ -17,6 +17,7 @@ import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow; +import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow; import com.ruoyi.customer.customermoney.domain.CustomerReceipt; import com.ruoyi.customer.customermoney.service.ICustomerMoneyService; @@ -118,4 +119,32 @@ public class CustomerMoneyController extends BaseController List list = customerMoneyService.selectFlowList(query); return getDataTable(list); } + + /** + * 查询额度流水列表 + */ + @PreAuthorize("@ss.hasPermi('customersupplier:customerinfo:list')") + @GetMapping("/credit/flow/list") + public TableDataInfo creditFlowList(CustomerCreditFlow query) + { + if (query == null || query.getCustomerId() == null || query.getCustomerId() == 0L) { + return getDataTable(Collections.emptyList()); + } + startPage(); + List list = customerMoneyService.selectCreditFlowList(query); + return getDataTable(list); + } + + /** + * 调整客户额度(信用额度/临时额度),调整过程留额度流水 + */ + @PreAuthorize("@ss.hasPermi('customer:credit:adjust')") + @Log(title = "客户额度调整", businessType = BusinessType.UPDATE) + @PostMapping("/credit/adjust") + public AjaxResult adjustCredit(@RequestBody CustomerCreditFlow flow) + { + flow.setCreateBy(getUsername()); + flow.setOperatorId(getUserId()); + return toAjax(customerMoneyService.adjustCredit(flow)); + } } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBalanceWriteoff.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBalanceWriteoff.java new file mode 100644 index 00000000..6a282884 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBalanceWriteoff.java @@ -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 + * + *

用客户账户余额核销应收账单,与 ar_receipt_writeoff(收款核销)并列, + * 两者共享 ar_bill.written_off_amount / unwritten_amount 缓存。

+ * + * @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; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java index 0f12d83e..94708716 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java @@ -4,6 +4,7 @@ import java.math.BigDecimal; import java.util.Date; import java.util.List; import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.format.annotation.DateTimeFormat; import com.ruoyi.common.core.domain.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -21,10 +22,13 @@ public class ArBill extends BaseEntity private Long customerId; private String customerCode; private String customerName; + @DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd") private Date billPeriodStart; + @DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd") private Date billPeriodEnd; + @DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd") private Date billDate; private String currency; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java index 7b2b7097..0d8b6742 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java @@ -15,7 +15,7 @@ public class ArBillDetail implements Serializable private Long detailId; private Long arBillId; - private String prcBillId; + private String prcOrderBillId; private Integer detailNo; private String orderNo; private String trackingNo; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java index 849e5464..fb656d88 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java @@ -14,7 +14,7 @@ public class ArBillDetailFee implements Serializable private Long detailFeeId; private Long detailId; - private String prcFeeId; + private String prcIncomeBillFeeId; private Integer feeNo; private String feeType; private String feeName; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillQuery.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillQuery.java new file mode 100644 index 00000000..dee0b83e --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillQuery.java @@ -0,0 +1,23 @@ +package com.ruoyi.ar.domain.vo; + +import java.util.Date; +import org.springframework.format.annotation.DateTimeFormat; +import com.ruoyi.ar.domain.ArBill; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** 应收账单列表查询参数。 */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ArBillQuery extends ArBill +{ + private static final long serialVersionUID = 1L; + + /** 账单日期查询起始日。 */ + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date billDateStart; + + /** 账单日期查询结束日。 */ + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date billDateEnd; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBalanceWriteoffMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBalanceWriteoffMapper.java new file mode 100644 index 00000000..cb4f0df2 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBalanceWriteoffMapper.java @@ -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 selectWriteoffList(ArBalanceWriteoff query); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java index 885133ee..1602382c 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java @@ -5,10 +5,11 @@ import java.util.Date; import java.util.List; import org.apache.ibatis.annotations.Param; import com.ruoyi.ar.domain.ArBill; +import com.ruoyi.ar.domain.vo.ArBillQuery; public interface ArBillMapper { - List selectArBillList(ArBill query); + List selectArBillList(ArBillQuery query); ArBill selectArBillById(Long arBillId); @@ -19,6 +20,9 @@ public interface ArBillMapper ArBill selectArBillByIdForUpdate(Long arBillId); + /** 查询客户可核销账单(已确认/部分核销),按账单日期升序,供余额核销选择 */ + List selectUnsettledByCustomer(@Param("customerId") Long customerId); + int insertArBill(ArBill bill); int updateArBillStatus(@Param("arBillId") Long arBillId, @Param("status") String status, diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBalanceWriteoffService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBalanceWriteoffService.java new file mode 100644 index 00000000..d39006cc --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBalanceWriteoffService.java @@ -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接口 + * + *

资金来源为客户账户余额(customer_account.balance),与收款核销 + * (ar_receipt_writeoff)并列;核销时三方联动:账户余额扣减、已用额度释放、 + * 账单已核销/未核销金额及状态更新。

+ * + * @author ruoyi + * @date 2026-09-16 + */ +public interface IArBalanceWriteoffService +{ + /** 查询客户可核销账单(已确认/部分核销,未核销额大于0) */ + List selectUnsettledBills(Long customerId); + + /** 查询余额核销记录 */ + List selectWriteoffList(ArBalanceWriteoff query); + + /** 用客户账户余额核销账单 */ + ArBalanceWriteoff writeoff(ArBalanceWriteoff cmd); + + /** 反核销(取消一笔有效余额核销,余额回补、账单金额回退) */ + void cancelWriteoff(Long writeoffId, String remark); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java index 0ffe36d7..89a7409a 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java @@ -8,11 +8,12 @@ import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.domain.vo.ArBillGenerateRequest; import com.ruoyi.ar.domain.vo.ArBillGenerateQuery; import com.ruoyi.ar.domain.vo.ArBillActionRequest; +import com.ruoyi.ar.domain.vo.ArBillQuery; import com.ruoyi.price.domain.PrcOrderBill; public interface IArBillService { - List selectArBillList(ArBill query); + List selectArBillList(ArBillQuery query); ArBill selectArBillById(Long arBillId); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBalanceWriteoffServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBalanceWriteoffServiceImpl.java new file mode 100644 index 00000000..9404a573 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBalanceWriteoffServiceImpl.java @@ -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 selectUnsettledBills(Long customerId) + { + if (customerId == null) + { + return List.of(); + } + return billMapper.selectUnsettledByCustomer(customerId); + } + + @Override + public List 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; + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java index 7f0fac01..836cddf0 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java @@ -50,6 +50,7 @@ import com.ruoyi.ar.service.IArBillService; import com.ruoyi.ar.domain.vo.ArBillActionRequest; import com.ruoyi.ar.domain.vo.ArBillGenerateQuery; import com.ruoyi.ar.domain.vo.ArBillGenerateRequest; +import com.ruoyi.ar.domain.vo.ArBillQuery; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.DateUtils; @@ -57,6 +58,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,9 +81,10 @@ public class ArBillServiceImpl implements IArBillService @Autowired private IArBillLogService logService; @Autowired private PrcOrderBillMapper sourceBillMapper; @Autowired private PrcIncomeBillFeeMapper sourceFeeMapper; + @Autowired private ICustomerMoneyService customerMoneyService; @Override - public List selectArBillList(ArBill query) + public List selectArBillList(ArBillQuery query) { List bills = billMapper.selectArBillList(query); for (ArBill bill : bills) @@ -224,7 +227,7 @@ public class ArBillServiceImpl implements IArBillService Map detailsBySourceBill = new HashMap<>(); for (ArBillDetail detail : detailMapper.selectByBillId(bill.getArBillId())) { - detailsBySourceBill.put(detail.getPrcBillId(), detail); + detailsBySourceBill.put(detail.getPrcOrderBillId(), detail); } List detailFees = new ArrayList<>(); List sourceFees = sourceBillIds.isEmpty() @@ -276,6 +279,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); } @@ -303,9 +309,9 @@ public class ArBillServiceImpl implements IArBillService List sourceBillIds = new ArrayList<>(); for (ArBillDetail detail : detailMapper.selectByBillId(arBillId)) { - if (!StringUtils.isEmpty(detail.getPrcBillId())) + if (!StringUtils.isEmpty(detail.getPrcOrderBillId())) { - sourceBillIds.add(detail.getPrcBillId()); + sourceBillIds.add(detail.getPrcOrderBillId()); } } if (billMapper.updateArBillStatus(arBillId, ArBillStatus.VOID.getCode(), remark, operator, currentStatus.getCode()) != 1) @@ -313,6 +319,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, @@ -459,7 +471,7 @@ public class ArBillServiceImpl implements IArBillService { ArBillDetail detail = new ArBillDetail(); detail.setArBillId(bill.getArBillId()); - detail.setPrcBillId(source.getBillId()); + detail.setPrcOrderBillId(source.getBillId()); detail.setDetailNo(detailNo); detail.setOrderNo(source.getOrderNo()); detail.setTrackingNo(source.getTrackingNo()); @@ -487,7 +499,7 @@ public class ArBillServiceImpl implements IArBillService { ArBillDetailFee fee = new ArBillDetailFee(); fee.setDetailId(detail.getDetailId()); - fee.setPrcFeeId(source.getFeeId()); + fee.setPrcIncomeBillFeeId(source.getFeeId()); fee.setFeeNo(feeNo); fee.setFeeType(source.getFeeType()); fee.setFeeName(source.getFeeName()); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/domain/BmsCustomerPriceSet.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/domain/BmsCustomerPriceSet.java new file mode 100644 index 00000000..36b8f02d --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/domain/BmsCustomerPriceSet.java @@ -0,0 +1,72 @@ +package com.ruoyi.customer.customerinfo.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 客户套餐绑定对象 prc_customer_price_set + * 客户管理-客户编辑-绑定套餐 使用;关联 prc_price_set 的字段仅用于页面展示 + * + * @author ruoyi + * @date 2026-09-16 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class BmsCustomerPriceSet extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 客户价格套餐ID */ + private Long customerPricesetId; + + /** 价格套餐ID */ + private Long priceSetId; + + /** 客户ID */ + private Long customerId; + + /** 客户代码 */ + private String customerCode; + + /** 客户名称 */ + private String customerName; + + /** 绑定优先级,数字越小越优先 */ + private Integer bindPriority; + + /** 价格开始生效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date prBegindate; + + /** 价格失效日期(空=长期) */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date prEnddate; + + /** 绑定备注 */ + private String priceSetNote; + + /* -------- 关联 prc_price_set 展示字段 -------- */ + + /** 套餐编码 */ + private String priceSetCode; + + /** 套餐名称 */ + private String priceSetName; + + /** 计价类型 FIXED=固定价 DISCOUNT=折扣 */ + private String setType; + + /** 套餐状态 D草稿 A审核中 E生效 R驳回 S停用 */ + private String priceSetStatus; + + /** 套餐生效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date setEffectiveDate; + + /** 套餐失效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date setExpireDate; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/domain/BmsPriceSetOption.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/domain/BmsPriceSetOption.java new file mode 100644 index 00000000..b73fd9de --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/domain/BmsPriceSetOption.java @@ -0,0 +1,42 @@ +package com.ruoyi.customer.customerinfo.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** + * 价格套餐(prc_price_set)简要对象 + * 仅用于客户编辑-绑定套餐的套餐下拉选择,不参与套餐维护 + * + * @author ruoyi + * @date 2026-09-16 + */ +@Data +public class BmsPriceSetOption +{ + /** 价格套餐ID */ + private Long priceSetId; + + /** 套餐编码 */ + private String priceSetCode; + + /** 套餐名称 */ + private String priceSetName; + + /** 计价类型 FIXED=固定价 DISCOUNT=折扣 */ + private String setType; + + /** 套餐状态 D草稿 A审核中 E生效 R驳回 S停用 */ + private String status; + + /** 生效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date effectiveDate; + + /** 失效日期(空=长期) */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date expireDate; + + /** 关键字(编码/名称模糊) */ + private String keyword; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/mapper/BmsCustomerMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/mapper/BmsCustomerMapper.java index 9815bfd0..672e5ad8 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/mapper/BmsCustomerMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/mapper/BmsCustomerMapper.java @@ -23,6 +23,15 @@ public interface BmsCustomerMapper */ public BmsCustomer selectBmsCustomerByCustomerId(Long customerId); + /** + * 只取客户档案物理额度列(credit_ed 在通用查询里是账户可用额度的子查询别名, + * 账户首次初始化时必须读取档案原始值) + * + * @param customerId 客户ID + * @return 仅含 creditEd/tempEd 的客户对象 + */ + public BmsCustomer selectRawCreditByCustomerId(Long customerId); + /** * 查询客户主列表 * @@ -103,6 +112,20 @@ public interface BmsCustomerMapper */ public int deleteBmsCustomerProfileAttachmentByCustomerId(Long customerId); + /** + * 同步客户档案额度字段(额度以客户余额账户为准,调整后回写档案) + * + * @param customerId 客户ID + * @param creditEd 信用额度 + * @param tempEd 临时额度 + * @param updateBy 更新人 + * @return 结果 + */ + public int updateCustomerCredit(@org.apache.ibatis.annotations.Param("customerId") Long customerId, + @org.apache.ibatis.annotations.Param("creditEd") java.math.BigDecimal creditEd, + @org.apache.ibatis.annotations.Param("tempEd") java.math.BigDecimal tempEd, + @org.apache.ibatis.annotations.Param("updateBy") String updateBy); + /** * 批量删除客户附件 * diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/mapper/BmsCustomerPriceSetMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/mapper/BmsCustomerPriceSetMapper.java new file mode 100644 index 00000000..5ceb215c --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/mapper/BmsCustomerPriceSetMapper.java @@ -0,0 +1,39 @@ +package com.ruoyi.customer.customerinfo.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.customer.customerinfo.domain.BmsCustomerPriceSet; +import com.ruoyi.customer.customerinfo.domain.BmsPriceSetOption; + +/** + * 客户套餐绑定 数据层(客户管理侧独立实现,操作 prc_customer_price_set) + * + * @author ruoyi + * @date 2026-09-16 + */ +public interface BmsCustomerPriceSetMapper +{ + /** 按客户查绑定列表(join prc_price_set 取套餐展示字段) */ + public List selectBindListByCustomerId(@Param("customerId") Long customerId); + + /** 按主键查绑定 */ + public BmsCustomerPriceSet selectBindById(Long customerPricesetId); + + /** 按客户ID查绑定(uk_customer_one_set 保证至多一条) */ + public BmsCustomerPriceSet selectBindByCustomerId(@Param("customerId") Long customerId); + + /** 新增绑定 */ + public int insertBind(BmsCustomerPriceSet bind); + + /** 更新绑定(允许更换套餐 price_set_id 与生效区间/优先级/备注) */ + public int updateBind(BmsCustomerPriceSet bind); + + /** 按主键批量解绑 */ + public int deleteBindByIds(Long[] customerPricesetIds); + + /** 套餐下拉选项(只列生效中套餐,可按关键字/类型过滤) */ + public List selectPriceSetOptionList(BmsPriceSetOption query); + + /** 按主键查套餐简要信息 */ + public BmsPriceSetOption selectPriceSetOptionById(@Param("priceSetId") Long priceSetId); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/IBmsCustomerPriceSetService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/IBmsCustomerPriceSetService.java new file mode 100644 index 00000000..1438217f --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/IBmsCustomerPriceSetService.java @@ -0,0 +1,28 @@ +package com.ruoyi.customer.customerinfo.service; + +import java.util.List; +import com.ruoyi.customer.customerinfo.domain.BmsCustomerPriceSet; +import com.ruoyi.customer.customerinfo.domain.BmsPriceSetOption; + +/** + * 客户套餐绑定 服务层(客户管理侧) + * + * @author ruoyi + * @date 2026-09-16 + */ +public interface IBmsCustomerPriceSetService +{ + /** 查询某客户的套餐绑定列表 */ + public List selectBindList(Long customerId); + + /** 查询可绑定的生效套餐选项 */ + public List selectOptionList(BmsPriceSetOption query); + + /** + * 保存客户套餐绑定(一个客户一条记录):已绑定则更新(支持更换套餐),未绑定则新增 + */ + public int saveBind(BmsCustomerPriceSet bind); + + /** 按绑定主键解绑 */ + public int deleteBindByIds(Long[] customerPricesetIds); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/impl/BmsCustomerPriceSetServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/impl/BmsCustomerPriceSetServiceImpl.java new file mode 100644 index 00000000..4c1910dd --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/impl/BmsCustomerPriceSetServiceImpl.java @@ -0,0 +1,116 @@ +package com.ruoyi.customer.customerinfo.service.impl; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.customer.customerinfo.domain.BmsCustomer; +import com.ruoyi.customer.customerinfo.domain.BmsCustomerPriceSet; +import com.ruoyi.customer.customerinfo.domain.BmsPriceSetOption; +import com.ruoyi.customer.customerinfo.mapper.BmsCustomerPriceSetMapper; +import com.ruoyi.customer.customerinfo.service.IBmsCustomerPriceSetService; +import com.ruoyi.customer.customerinfo.service.IBmsCustomerService; + +/** + * 客户套餐绑定 服务实现(客户管理侧,独立于价格套餐维护模块) + * + * @author ruoyi + * @date 2026-09-16 + */ +@Service +public class BmsCustomerPriceSetServiceImpl implements IBmsCustomerPriceSetService +{ + @Autowired + private BmsCustomerPriceSetMapper bindMapper; + + @Autowired + private IBmsCustomerService customerService; + + @Override + public List selectBindList(Long customerId) + { + if (customerId == null) + { + return List.of(); + } + return bindMapper.selectBindListByCustomerId(customerId); + } + + @Override + public List selectOptionList(BmsPriceSetOption query) + { + return bindMapper.selectPriceSetOptionList(query); + } + + @Override + public int saveBind(BmsCustomerPriceSet bind) + { + if (bind.getCustomerId() == null) + { + throw new ServiceException("客户ID不能为空"); + } + if (bind.getPriceSetId() == null) + { + throw new ServiceException("请选择要绑定的套餐"); + } + BmsCustomer customer = customerService.selectBmsCustomerByCustomerId(bind.getCustomerId()); + if (customer == null) + { + throw new ServiceException("客户不存在或已被删除"); + } + BmsPriceSetOption set = bindMapper.selectPriceSetOptionById(bind.getPriceSetId()); + if (set == null) + { + throw new ServiceException("所选套餐不存在"); + } + if (!"E".equals(set.getStatus())) + { + throw new ServiceException("仅生效中的套餐允许绑定,当前套餐状态不可用"); + } + if (bind.getPrBegindate() == null) + { + bind.setPrBegindate(set.getEffectiveDate()); + } + if (bind.getPrBegindate() == null) + { + throw new ServiceException("绑定生效日期不能为空"); + } + if (bind.getPrEnddate() != null && bind.getPrBegindate().after(bind.getPrEnddate())) + { + throw new ServiceException("失效日期不能早于生效日期"); + } + if (bind.getBindPriority() == null || bind.getBindPriority() < 1) + { + bind.setBindPriority(1); + } + + bind.setCustomerCode(customer.getCustomerCode()); + bind.setCustomerName(customer.getCustomerName()); + + BmsCustomerPriceSet existing = bindMapper.selectBindByCustomerId(bind.getCustomerId()); + try + { + if (existing != null) + { + bind.setCustomerPricesetId(existing.getCustomerPricesetId()); + return bindMapper.updateBind(bind); + } + return bindMapper.insertBind(bind); + } + catch (DuplicateKeyException e) + { + throw new ServiceException("该客户已存在套餐绑定,请勿重复绑定"); + } + } + + @Override + public int deleteBindByIds(Long[] customerPricesetIds) + { + if (customerPricesetIds == null || customerPricesetIds.length == 0) + { + throw new ServiceException("请选择要解绑的记录"); + } + return bindMapper.deleteBindByIds(customerPricesetIds); + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/impl/BmsCustomerServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/impl/BmsCustomerServiceImpl.java index 5bf4ebdc..57525228 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/impl/BmsCustomerServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customerinfo/service/impl/BmsCustomerServiceImpl.java @@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional; import com.ruoyi.customer.customerinfo.mapper.BmsCustomerMapper; import com.ruoyi.customer.customerinfo.domain.BmsCustomer; import com.ruoyi.customer.customerinfo.service.IBmsCustomerService; +import com.ruoyi.customer.customermoney.service.ICustomerMoneyService; /** * 客户主Service业务层处理 @@ -27,6 +28,9 @@ public class BmsCustomerServiceImpl implements IBmsCustomerService @Autowired private BmsCustomerMapper bmsCustomerMapper; + @Autowired + private ICustomerMoneyService customerMoneyService; + /** * 查询客户主 * @@ -226,6 +230,8 @@ public class BmsCustomerServiceImpl implements IBmsCustomerService saveBmsCustomerAttachments(bmsCustomer.getCustomerId(), bmsCustomer.getAttachmentList()); saveBmsCustomerCompanies(bmsCustomer.getCustomerId(), bmsCustomer.getCompanyList()); saveBmsCustomerContacts(bmsCustomer.getCustomerId(), bmsCustomer.getContactList()); + // 新建客户即初始化默认CNY账户:余额0、总额度1000 CNY,写INIT流水 + customerMoneyService.initDefaultCnyAccount(bmsCustomer.getCustomerId()); } return rows; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java index 2e36641c..4f1b6bf2 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerAccount.java @@ -1,6 +1,8 @@ package com.ruoyi.customer.customermoney.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; @@ -35,9 +37,17 @@ public class CustomerAccount extends BaseEntity /** 总额度 */ private BigDecimal totalCredit; - /** 额外额度 */ + /** 额外额度(临时额度) */ private BigDecimal extraCredit; + /** 临时额度生效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date extraEffectiveDate; + + /** 临时额度失效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date extraExpireDate; + /** 已用额度 */ private BigDecimal usedCredit; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditFlow.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditFlow.java new file mode 100644 index 00000000..0bce0f0c --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/domain/CustomerCreditFlow.java @@ -0,0 +1,93 @@ +package com.ruoyi.customer.customermoney.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; + +/** + * 客户额度流水 customer_credit_flow + * + * @author ruoyi + * @date 2026-09-16 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class CustomerCreditFlow extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + /** 额度类型:信用额度(长期) */ + public static final String CREDIT_TYPE_CREDIT = "CREDIT"; + /** 额度类型:临时额度 */ + public static final String CREDIT_TYPE_TEMP = "TEMP"; + + /** 变动类型:初始化 */ + public static final String CHANGE_INIT = "INIT"; + /** 变动类型:手工调整 */ + public static final String CHANGE_ADJUST = "ADJUST"; + /** 变动类型:到期自动失效 */ + public static final String CHANGE_EXPIRE = "EXPIRE"; + + /** 调整方式:设置为 */ + public static final String MODE_SET = "SET"; + /** 调整方式:增加 */ + public static final String MODE_ADD = "ADD"; + /** 调整方式:减少 */ + public static final String MODE_SUB = "SUB"; + + /** 主键 */ + private Long id; + + /** 流水号 */ + private String flowNo; + + /** 客户ID */ + private Long customerId; + + /** 客户账户ID */ + private Long accountId; + + /** 额度类型:CREDIT信用额度 TEMP临时额度 */ + private String creditType; + + /** 变动类型:INIT初始化 ADJUST调整 EXPIRE到期失效 */ + private String changeType; + + /** 变动方向:1增加 -1减少 0持平 */ + private Integer direction; + + /** 变动前额度 */ + private BigDecimal beforeAmount; + + /** 变动额度(恒为非负数,方向看 direction) */ + private BigDecimal changeAmount; + + /** 变动后额度 */ + private BigDecimal afterAmount; + + /** 变动时已用额度快照 */ + private BigDecimal usedCredit; + + /** 生效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date effectiveDate; + + /** 失效日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date expireDate; + + /** 调整原因 */ + private String reason; + + /** 调整方式(仅ADJUST流水有值):SET/ADD/SUB */ + private String adjustMode; + + /** 操作人ID */ + private Long operatorId; + + /** 删除标志(0存在 1删除) */ + private String delFlag; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java index 0f7795fc..ec07a8b6 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/mapper/CustomerMoneyMapper.java @@ -4,6 +4,7 @@ import java.util.List; import org.apache.ibatis.annotations.Param; import com.ruoyi.customer.customermoney.domain.CustomerAccount; import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow; +import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow; import com.ruoyi.customer.customermoney.domain.CustomerReceipt; /** @@ -33,6 +34,17 @@ public interface CustomerMoneyMapper /** 乐观锁更新余额 */ int updateAccountBalance(CustomerAccount account); + /** 乐观锁更新额度(信用额度/临时额度/有效期) */ + int updateAccountCredit(CustomerAccount account); + + /* ================= 额度流水 ================= */ + + /** 查询额度流水 */ + List selectCreditFlowList(CustomerCreditFlow query); + + /** 新增额度流水 */ + int insertCreditFlow(CustomerCreditFlow flow); + /* ================= 客户收款 ================= */ /** 查询收款单列表 */ diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java index 096ad8a2..daa468fa 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/ICustomerMoneyService.java @@ -1,8 +1,10 @@ package com.ruoyi.customer.customermoney.service; +import java.math.BigDecimal; import java.util.List; import com.ruoyi.customer.customermoney.domain.CustomerAccount; import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow; +import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow; import com.ruoyi.customer.customermoney.domain.CustomerReceipt; /** @@ -16,6 +18,15 @@ public interface ICustomerMoneyService /** 查询客户各币种账户 */ List selectAccountList(Long customerId); + /** + * 新建客户时初始化默认CNY账户(余额0,默认总额度1000 CNY),并写 INIT 额度流水。 + * 已存在CNY账户时不重复创建。 + * + * @param customerId 客户ID + * @return 1=已初始化或已存在 + */ + int initDefaultCnyAccount(Long customerId); + /** 查询收款单列表 */ List selectReceiptList(CustomerReceipt query); @@ -36,4 +47,46 @@ public interface ICustomerMoneyService /** 查询余额流水列表 */ List selectFlowList(CustomerBalanceFlow query); + + /** 查询额度流水列表 */ + List selectCreditFlowList(CustomerCreditFlow query); + + /** + * 调整客户额度(信用额度/临时额度) + * 事务内:锁定CNY账户(不存在则初始化)-> 校验(不能为负、不能低于已用额度) + * -> 乐观锁更新额度 -> 回写客户档案额度字段 -> 写额度流水 + */ + 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); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java index 3889c536..69ce261c 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/customer/customermoney/service/impl/CustomerMoneyServiceImpl.java @@ -2,6 +2,7 @@ package com.ruoyi.customer.customermoney.service.impl; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -14,10 +15,12 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.customer.customerinfo.domain.BmsCustomer; import com.ruoyi.customer.customerinfo.domain.BmsCustomerAttachment; import com.ruoyi.customer.customerinfo.mapper.BmsCustomerMapper; import com.ruoyi.customer.customermoney.domain.CustomerAccount; import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow; +import com.ruoyi.customer.customermoney.domain.CustomerCreditFlow; import com.ruoyi.customer.customermoney.domain.CustomerReceipt; import com.ruoyi.customer.customermoney.mapper.CustomerMoneyMapper; import com.ruoyi.customer.customermoney.service.ICustomerMoneyService; @@ -37,6 +40,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService /** 本位币币种 */ private static final String BASE_CURRENCY = BmsExchangeRate.BASE_CURRENCY; + /** 新客户默认总额度(CNY) */ + private static final BigDecimal DEFAULT_CREDIT = new BigDecimal("1000"); /** 金额保留位数 */ private static final int MONEY_SCALE = 2; @@ -50,9 +55,25 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService private IBmsExchangeRateService exchangeRateService; @Override + @Transactional(rollbackFor = Exception.class) public List selectAccountList(Long customerId) { - return customerMoneyMapper.selectAccountListByCustomerId(customerId); + List list = customerMoneyMapper.selectAccountListByCustomerId(customerId); + // 查询时顺带处理临时额度到期自动失效(仅CNY账户),保证卡片展示为最新值 + for (int i = 0; i < list.size(); i++) + { + CustomerAccount account = list.get(i); + if (BASE_CURRENCY.equals(account.getCurrency()) && isTempCreditExpired(account)) + { + CustomerAccount locked = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY); + expireTempCreditIfNeeded(locked); + if (locked != null) + { + list.set(i, locked); + } + } + } + return list; } @Override @@ -167,23 +188,8 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService ? receipt.getBaseAmount() : originalAmount.multiply(rate).setScale(MONEY_SCALE, RoundingMode.HALF_UP); - // 1. 行锁锁定客户本位币(CNY)账户,不存在则自动初始化。无论收什么币种,一律折算 CNY 入账 - CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(receipt.getCustomerId(), BASE_CURRENCY); - if (account == null) - { - account = new CustomerAccount(); - account.setCustomerId(receipt.getCustomerId()); - account.setCurrency(BASE_CURRENCY); - account.setBalance(BigDecimal.ZERO); - account.setFrozenBalance(BigDecimal.ZERO); - account.setTotalCredit(BigDecimal.ZERO); - account.setExtraCredit(BigDecimal.ZERO); - account.setUsedCredit(BigDecimal.ZERO); - account.setTax(BigDecimal.ZERO); - account.setVersion(0); - account.setCreateBy(username); - customerMoneyMapper.insertAccount(account); - } + // 1. 行锁锁定客户本位币(CNY)账户,不存在则按客户档案额度自动初始化。无论收什么币种,一律折算 CNY 入账 + CustomerAccount account = getOrInitCnyAccount(receipt.getCustomerId(), username); // 2. 本位币余额变动(预充值/混合增加余额;还款核销释放已用额度) BigDecimal beforeBalance = nz(account.getBalance()); @@ -191,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) { @@ -260,6 +262,503 @@ public class CustomerMoneyServiceImpl implements ICustomerMoneyService return customerMoneyMapper.selectFlowList(query); } + @Override + public List selectCreditFlowList(CustomerCreditFlow query) + { + return customerMoneyMapper.selectCreditFlowList(query); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int adjustCredit(CustomerCreditFlow cmd) + { + if (cmd == null || cmd.getCustomerId() == null) + { + throw new ServiceException("客户ID不能为空"); + } + Long customerId = cmd.getCustomerId(); + String creditType = cmd.getCreditType(); + if (!CustomerCreditFlow.CREDIT_TYPE_CREDIT.equals(creditType) + && !CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType)) + { + throw new ServiceException("额度类型不正确"); + } + String adjustMode = cmd.getAdjustMode(); + if (!CustomerCreditFlow.MODE_SET.equals(adjustMode) + && !CustomerCreditFlow.MODE_ADD.equals(adjustMode) + && !CustomerCreditFlow.MODE_SUB.equals(adjustMode)) + { + throw new ServiceException("调整方式不正确"); + } + BigDecimal input = nz(cmd.getChangeAmount()); + if (input.compareTo(BigDecimal.ZERO) <= 0) + { + throw new ServiceException("调整金额必须大于0"); + } + if (!StringUtils.hasText(cmd.getReason())) + { + throw new ServiceException("请填写调整原因"); + } + String reason = cmd.getReason().trim(); + if (reason.length() > 255) + { + throw new ServiceException("调整原因不能超过255个字"); + } + String username = SecurityUtils.getUsername(); + Long userId = SecurityUtils.getUserId(); + boolean isTemp = CustomerCreditFlow.CREDIT_TYPE_TEMP.equals(creditType); + + // 1. 行锁锁定CNY账户(不存在则按客户档案额度初始化创建);先处理临时额度过期 + CustomerAccount account = getOrInitCnyAccount(customerId, username); + expireTempCreditIfNeeded(account); + + // 2. 计算调整结果 + BigDecimal before = isTemp ? nz(account.getExtraCredit()) : nz(account.getTotalCredit()); + BigDecimal otherCredit = isTemp ? nz(account.getTotalCredit()) : nz(account.getExtraCredit()); + BigDecimal after; + switch (adjustMode) + { + case CustomerCreditFlow.MODE_SET: + after = input; + break; + case CustomerCreditFlow.MODE_ADD: + after = before.add(input); + break; + default: + after = before.subtract(input); + break; + } + after = after.setScale(MONEY_SCALE, RoundingMode.HALF_UP); + if (after.compareTo(BigDecimal.ZERO) < 0) + { + throw new ServiceException("调整后额度不能为负数"); + } + if (otherCredit.add(after).compareTo(nz(account.getUsedCredit())) < 0) + { + throw new ServiceException("调整后授信总额度不能小于已用额度:" + + nz(account.getUsedCredit()).toPlainString()); + } + if (after.compareTo(before) == 0) + { + throw new ServiceException("额度未发生变化,无需调整"); + } + + // 3. 临时额度有效期校验(不允许预约未来生效,失效日必须晚于生效日) + Date effectiveDate = null; + Date expireDate = null; + if (isTemp) + { + LocalDate today = LocalDate.now(); + LocalDate eff = cmd.getEffectiveDate() != null ? toLocalDate(cmd.getEffectiveDate()) : today; + if (eff.isAfter(today)) + { + throw new ServiceException("生效日期不能晚于今天,需要未来生效请在生效当天再设置"); + } + effectiveDate = toSqlDate(eff); + if (cmd.getExpireDate() != null) + { + LocalDate exp = toLocalDate(cmd.getExpireDate()); + if (!exp.isAfter(eff)) + { + throw new ServiceException("失效日期必须晚于生效日期"); + } + expireDate = toSqlDate(exp); + } + account.setExtraCredit(after); + account.setExtraEffectiveDate(effectiveDate); + account.setExtraExpireDate(expireDate); + } + else + { + account.setTotalCredit(after); + } + account.setUpdateBy(username); + + // 4. 乐观锁更新额度 + int rows = customerMoneyMapper.updateAccountCredit(account); + if (rows == 0) + { + throw new ServiceException("账户额度已被其他操作变动,请刷新后重试"); + } + account.setVersion(account.getVersion() + 1); + + // 5. 回写客户档案额度字段(额度唯一数据源为账户,档案仅做同步展示) + bmsCustomerMapper.updateCustomerCredit(customerId, + nz(account.getTotalCredit()), nz(account.getExtraCredit()), username); + + // 6. 写额度流水 + CustomerCreditFlow flow = new CustomerCreditFlow(); + flow.setFlowNo(generateNo("EDLS")); + flow.setCustomerId(customerId); + flow.setAccountId(account.getId()); + flow.setCreditType(creditType); + flow.setChangeType(CustomerCreditFlow.CHANGE_ADJUST); + int direction = after.compareTo(before) > 0 ? 1 : -1; + flow.setDirection(direction); + flow.setBeforeAmount(before); + flow.setChangeAmount(after.subtract(before).abs()); + flow.setAfterAmount(after); + flow.setUsedCredit(nz(account.getUsedCredit())); + flow.setEffectiveDate(effectiveDate); + flow.setExpireDate(expireDate); + flow.setReason(reason); + flow.setAdjustMode(adjustMode); + flow.setOperatorId(userId); + flow.setCreateBy(username); + flow.setRemark(cmd.getRemark()); + flow.setDelFlag("0"); + customerMoneyMapper.insertCreditFlow(flow); + 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; + } + + /* ================= 额度私有方法 ================= */ + + /** + * 新建客户时默认初始化CNY账户:余额0,总额度默认1000 CNY + * (客户档案已填信用额度且大于0时按档案值),并写 INIT 流水;已存在则跳过。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int initDefaultCnyAccount(Long customerId) + { + if (customerId == null) + { + throw new ServiceException("客户ID不能为空"); + } + String username = SecurityUtils.getUsername(); + CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY); + if (account != null) + { + return 1; + } + BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId); + if (customer == null) + { + throw new ServiceException("客户不存在"); + } + BigDecimal initCredit = nz(customer.getCreditEd()); + if (initCredit.compareTo(BigDecimal.ZERO) <= 0) + { + initCredit = DEFAULT_CREDIT; + } + BigDecimal initTemp = nz(customer.getTempEd()); + account = createCnyAccount(customerId, initCredit, initTemp, username); + // 档案额度与实际初始额度保持一致 + bmsCustomerMapper.updateCustomerCredit(customerId, initCredit, initTemp, username); + writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT, + initCredit, null, null, username, "新客户默认初始化额度1000 CNY"); + if (initTemp.compareTo(BigDecimal.ZERO) > 0) + { + writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP, + initTemp, null, null, username, "新客户初始化临时额度"); + } + return 1; + } + + /** 行锁获取客户CNY账户,不存在则按客户档案当前额度初始化创建 */ + private CustomerAccount getOrInitCnyAccount(Long customerId, String username) + { + CustomerAccount account = customerMoneyMapper.selectAccountForUpdate(customerId, BASE_CURRENCY); + if (account == null) + { + BmsCustomer customer = bmsCustomerMapper.selectRawCreditByCustomerId(customerId); + if (customer == null) + { + throw new ServiceException("客户不存在"); + } + BigDecimal initCredit = nz(customer.getCreditEd()); + BigDecimal initTemp = nz(customer.getTempEd()); + account = createCnyAccount(customerId, initCredit, initTemp, username); + writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_CREDIT, + initCredit, null, null, username, "首次创建CNY账户,从客户档案同步初始额度"); + if (initTemp.compareTo(BigDecimal.ZERO) > 0) + { + writeInitFlow(account, CustomerCreditFlow.CREDIT_TYPE_TEMP, + initTemp, null, null, username, "首次创建CNY账户,从客户档案同步临时额度"); + } + } + return account; + } + + /** 创建CNY账户(余额0,已用0) */ + private CustomerAccount createCnyAccount(Long customerId, BigDecimal totalCredit, + BigDecimal extraCredit, String username) + { + CustomerAccount account = new CustomerAccount(); + account.setCustomerId(customerId); + account.setCurrency(BASE_CURRENCY); + account.setBalance(BigDecimal.ZERO); + account.setFrozenBalance(BigDecimal.ZERO); + account.setTotalCredit(totalCredit); + account.setExtraCredit(extraCredit); + account.setUsedCredit(BigDecimal.ZERO); + account.setTax(BigDecimal.ZERO); + account.setVersion(0); + account.setCreateBy(username); + customerMoneyMapper.insertAccount(account); + return account; + } + + /** + * 账户首次初始化时写额度 INIT 流水(从客户档案额度同步) + */ + private void writeInitFlow(CustomerAccount account, String creditType, + BigDecimal initAmount, Date effectiveDate, Date expireDate, + String username, String remark) + { + CustomerCreditFlow flow = new CustomerCreditFlow(); + flow.setFlowNo(generateNo("EDLS")); + flow.setCustomerId(account.getCustomerId()); + flow.setAccountId(account.getId()); + flow.setCreditType(creditType); + flow.setChangeType(CustomerCreditFlow.CHANGE_INIT); + flow.setDirection(initAmount.compareTo(BigDecimal.ZERO) > 0 ? 1 : 0); + flow.setBeforeAmount(BigDecimal.ZERO); + flow.setChangeAmount(initAmount); + flow.setAfterAmount(initAmount); + flow.setUsedCredit(nz(account.getUsedCredit())); + flow.setEffectiveDate(effectiveDate); + flow.setExpireDate(expireDate); + flow.setReason("账户初始化"); + flow.setOperatorId(SecurityUtils.getUserId()); + flow.setCreateBy(username); + flow.setRemark(remark); + flow.setDelFlag("0"); + customerMoneyMapper.insertCreditFlow(flow); + } + + /** 临时额度是否已过期:有失效日期且今天已晚于失效日(失效日当天仍有效) */ + private boolean isTempCreditExpired(CustomerAccount account) + { + return account != null + && nz(account.getExtraCredit()).compareTo(BigDecimal.ZERO) > 0 + && account.getExtraExpireDate() != null + && LocalDate.now().isAfter(toLocalDate(account.getExtraExpireDate())); + } + + /** + * 临时额度到期自动失效:额度清零 -> 回写客户档案 -> 写EXPIRE流水。 + * 入参必须是事务内行锁查出的账户。 + */ + private void expireTempCreditIfNeeded(CustomerAccount account) + { + if (account == null || !isTempCreditExpired(account)) + { + return; + } + String username = SecurityUtils.getUsername(); + BigDecimal before = nz(account.getExtraCredit()); + account.setExtraCredit(BigDecimal.ZERO); + account.setUpdateBy(username); + int rows = customerMoneyMapper.updateAccountCredit(account); + if (rows == 0) + { + throw new ServiceException("账户额度已被其他操作变动,请刷新后重试"); + } + account.setVersion(account.getVersion() + 1); + bmsCustomerMapper.updateCustomerCredit(account.getCustomerId(), + nz(account.getTotalCredit()), BigDecimal.ZERO, username); + + CustomerCreditFlow flow = new CustomerCreditFlow(); + flow.setFlowNo(generateNo("EDLS")); + flow.setCustomerId(account.getCustomerId()); + flow.setAccountId(account.getId()); + flow.setCreditType(CustomerCreditFlow.CREDIT_TYPE_TEMP); + flow.setChangeType(CustomerCreditFlow.CHANGE_EXPIRE); + flow.setDirection(-1); + flow.setBeforeAmount(before); + flow.setChangeAmount(before); + flow.setAfterAmount(BigDecimal.ZERO); + flow.setUsedCredit(nz(account.getUsedCredit())); + flow.setEffectiveDate(account.getExtraEffectiveDate()); + flow.setExpireDate(account.getExtraExpireDate()); + flow.setReason("临时额度到期自动失效"); + flow.setOperatorId(SecurityUtils.getUserId()); + flow.setCreateBy(username); + flow.setRemark("系统在访问账户时检测到临时额度已过失效日期,自动置零"); + flow.setDelFlag("0"); + customerMoneyMapper.insertCreditFlow(flow); + } + + private LocalDate toLocalDate(Date date) + { + return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + } + + private Date toSqlDate(LocalDate localDate) + { + return java.sql.Date.valueOf(localDate); + } + /* ================= 私有方法 ================= */ /** 保存收款附件到客户附件表 */ diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBalanceWriteoffMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBalanceWriteoffMapper.xml new file mode 100644 index 00000000..012299b3 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBalanceWriteoffMapper.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + 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() + ) + + + + update ar_balance_writeoff + set account_id = #{accountId}, + balance_flow_id = #{balanceFlowId} + where writeoff_id = #{writeoffId} + + + + + + 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' + + + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml index 440870ac..ab360a50 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml @@ -6,7 +6,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + @@ -24,13 +24,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select detail_id, ar_bill_id, prc_bill_id, detail_no, order_no, tracking_no, receive_time, + select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time, product_code, product_name, goods_description, country_code, country_name, dest_postcode, actual_weight, volume_weight, charge_weight, pkg_length, pkg_width, pkg_height, currency, total_amount, remark, create_by, create_time @@ -42,7 +42,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + @@ -61,11 +61,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -80,6 +81,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where ar_bill_id = #{arBillId} for update + + insert into ar_bill ( bill_no, bill_name, customer_id, customer_code, customer_name, bill_period_start, bill_period_end, diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/customer/BmsCustomerMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/customer/BmsCustomerMapper.xml index f5009886..7cae5ba2 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/customer/BmsCustomerMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/customer/BmsCustomerMapper.xml @@ -198,6 +198,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and (biz_type = 'customer' or biz_type is null or biz_type = '') + + + update bms_customer + set credit_ed = #{creditEd}, + temp_ed = #{tempEd}, + update_by = #{updateBy}, + update_time = now() + where customer_id = #{customerId} + + + + + update bms_customer_attachment set del_flag = '2' where attachment_id in @@ -287,12 +306,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" service_user_id = #{serviceUserId}, service_user = #{serviceUser}, settlement_type = #{settlementType}, - temp_ed = #{tempEd}, account_balance = #{accountBalance}, foreign_currency_balance = #{foreignCurrencyBalance}, weight_threshold = #{weightThreshold}, throw_ratio = #{throwRatio}, - credit_ed = #{creditEd}, product_ids = #{productIds}, remark = #{remark}, create_by = #{createBy}, diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/customer/BmsCustomerPriceSetMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/customer/BmsCustomerPriceSetMapper.xml new file mode 100644 index 00000000..e16b8d51 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/customer/BmsCustomerPriceSetMapper.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select c.customer_priceset_id, c.price_set_id, c.customer_id, c.customer_code, c.customer_name, + c.bind_priority, c.pr_begindate, c.pr_enddate, c.price_set_note, + c.created_by, c.create_time, c.updated_by, c.update_time, + s.price_set_code, s.price_set_name, s.set_type, + s.status as price_set_status, + s.effective_date as set_effective_date, s.expire_date as set_expire_date + from prc_customer_price_set c + left join prc_price_set s on s.price_set_id = c.price_set_id + + + + + + + + + + insert into prc_customer_price_set ( + price_set_id, customer_id, customer_code, customer_name, + bind_priority, pr_begindate, pr_enddate, price_set_note, + created_by, create_time + ) values ( + #{priceSetId}, #{customerId}, #{customerCode}, #{customerName}, + #{bindPriority}, #{prBegindate}, #{prEnddate}, #{priceSetNote}, + #{createBy}, sysdate() + ) + + + + update prc_customer_price_set + set price_set_id = #{priceSetId}, + customer_code = #{customerCode}, + customer_name = #{customerName}, + bind_priority = #{bindPriority}, + pr_begindate = #{prBegindate}, + pr_enddate = #{prEnddate}, + price_set_note = #{priceSetNote}, + updated_by = #{updateBy}, + update_time = sysdate() + where customer_priceset_id = #{customerPricesetId} + + + + delete from prc_customer_price_set where customer_priceset_id in + + #{id} + + + + + + + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml index 1041fe83..c307ca04 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/customer/CustomerMoneyMapper.xml @@ -13,6 +13,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + @@ -25,7 +27,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select id, customer_id, currency, balance, frozen_balance, total_credit, extra_credit, used_credit, + select id, customer_id, currency, balance, frozen_balance, total_credit, extra_credit, + extra_effective_date, extra_expire_date, used_credit, tax, version, create_by, create_time, update_by, update_time, remark, del_flag from customer_account @@ -90,6 +93,112 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where id = #{id} and version = #{version} and del_flag = '0' + + + update customer_account + set total_credit = #{totalCredit}, + extra_credit = #{extraCredit}, + extra_effective_date = #{extraEffectiveDate}, + extra_expire_date = #{extraExpireDate}, + version = version + 1, + update_by = #{updateBy}, + update_time = now() + where id = #{id} and version = #{version} and del_flag = '0' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, flow_no, customer_id, account_id, credit_type, change_type, direction, + before_amount, change_amount, after_amount, used_credit, + effective_date, expire_date, reason, adjust_mode, operator_id, + create_by, create_time, update_by, update_time, remark, del_flag + from customer_credit_flow + + + + + + insert into customer_credit_flow + + flow_no, + customer_id, + account_id, + credit_type, + change_type, + direction, + before_amount, + change_amount, + after_amount, + used_credit, + effective_date, + expire_date, + reason, + adjust_mode, + operator_id, + create_by, + remark, + del_flag, + + + #{flowNo}, + #{customerId}, + #{accountId}, + #{creditType}, + #{changeType}, + #{direction}, + #{beforeAmount}, + #{changeAmount}, + #{afterAmount}, + #{usedCredit}, + #{effectiveDate}, + #{expireDate}, + #{reason}, + #{adjustMode}, + #{operatorId}, + #{createBy}, + #{remark}, + #{delFlag}, + + + diff --git a/bms-back/sql/ar/ar_balance_writeoff.sql b/bms-back/sql/ar/ar_balance_writeoff.sql new file mode 100644 index 00000000..dc598488 --- /dev/null +++ b/bms-back/sql/ar/ar_balance_writeoff.sql @@ -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); diff --git a/bms-back/sql/ar/ar_bill.sql b/bms-back/sql/ar/ar_bill.sql index cf415aee..fc963a33 100644 --- a/bms-back/sql/ar/ar_bill.sql +++ b/bms-back/sql/ar/ar_bill.sql @@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS ar_bill ( CREATE TABLE IF NOT EXISTS ar_bill_detail ( detail_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '账单订单明细ID', ar_bill_id BIGINT(20) NOT NULL COMMENT '应收账单ID', - prc_bill_id VARCHAR(36) NOT NULL COMMENT '来源订单计费ID,保持来源类型', + prc_order_bill_id VARCHAR(36) NOT NULL COMMENT '来源计费订单ID,对应 prc_order_bill.bill_id', detail_no INT(11) NOT NULL COMMENT '账单内顺序号', order_no VARCHAR(64) NOT NULL COMMENT '订单号快照', tracking_no VARCHAR(64) DEFAULT NULL COMMENT '运单号快照', @@ -66,16 +66,16 @@ CREATE TABLE IF NOT EXISTS ar_bill_detail ( create_by VARCHAR(64) NOT NULL COMMENT '创建人', create_time DATETIME NOT NULL COMMENT '创建时间', PRIMARY KEY (detail_id), - UNIQUE KEY uk_ar_bill_detail_source (ar_bill_id, prc_bill_id), + UNIQUE KEY uk_ar_bill_detail_source (ar_bill_id, prc_order_bill_id), UNIQUE KEY uk_ar_bill_detail_no (ar_bill_id, detail_no), - KEY idx_ar_bill_detail_source (prc_bill_id), + KEY idx_ar_bill_detail_source (prc_order_bill_id), CONSTRAINT fk_ar_bill_detail_bill FOREIGN KEY (ar_bill_id) REFERENCES ar_bill (ar_bill_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单订单快照'; CREATE TABLE IF NOT EXISTS ar_bill_detail_fee ( detail_fee_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '账单费用明细ID', detail_id BIGINT(20) NOT NULL COMMENT '账单订单明细ID', - prc_fee_id VARCHAR(36) NOT NULL COMMENT '来源费用ID,保持来源类型', + prc_income_bill_fee_id VARCHAR(36) NOT NULL COMMENT '来源收入费用ID,对应 prc_income_bill_fee.fee_id', fee_no INT(11) NOT NULL COMMENT '订单内费用顺序号', fee_type VARCHAR(30) NOT NULL COMMENT '费用类型快照', fee_name VARCHAR(100) DEFAULT NULL COMMENT '费用名称快照', @@ -91,9 +91,9 @@ CREATE TABLE IF NOT EXISTS ar_bill_detail_fee ( create_by VARCHAR(64) NOT NULL COMMENT '创建人', create_time DATETIME NOT NULL COMMENT '创建时间', PRIMARY KEY (detail_fee_id), - UNIQUE KEY uk_ar_detail_prc_fee (detail_id, prc_fee_id), + UNIQUE KEY uk_ar_detail_prc_income_fee (detail_id, prc_income_bill_fee_id), KEY idx_ar_detail_fee_type (fee_type), - KEY idx_ar_detail_prc_fee (prc_fee_id), + KEY idx_ar_detail_prc_income_fee (prc_income_bill_fee_id), KEY idx_ar_detail_fee_order (detail_id, fee_no, detail_fee_id), CONSTRAINT fk_ar_detail_fee_detail FOREIGN KEY (detail_id) REFERENCES ar_bill_detail (detail_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单费用快照'; diff --git a/bms-back/sql/ar/ar_bill_cutoff_status_migration.sql b/bms-back/sql/ar/ar_bill_cutoff_status_migration.sql index e9f53907..eb761612 100644 --- a/bms-back/sql/ar/ar_bill_cutoff_status_migration.sql +++ b/bms-back/sql/ar/ar_bill_cutoff_status_migration.sql @@ -1,5 +1,6 @@ -- 应收账单:订单占用状态 + 单日期截止日迁移 -- 适用于已经执行过 ar/ar_bill.sql、price/bill.sql 的数据库。 +-- 若数据库中的应收明细仍使用旧来源ID列名,请先执行 ar_bill_source_id_rename.sql。 -- 请在业务低峰期执行;本脚本只做字段、索引和历史状态初始化,不删除账单明细。 SET NAMES utf8mb4; SET @db_name := DATABASE(); @@ -60,11 +61,11 @@ DEALLOCATE PREPARE stmt; -- 3. 根据已有账单明细回填历史订单状态。作废账单不占用订单。 UPDATE prc_order_bill o JOIN ( - SELECT DISTINCT d.prc_bill_id + SELECT DISTINCT d.prc_order_bill_id FROM ar_bill_detail d JOIN ar_bill b ON b.ar_bill_id = d.ar_bill_id WHERE b.status <> 'VOID' -) billed ON billed.prc_bill_id = o.bill_id +) billed ON billed.prc_order_bill_id = o.bill_id SET o.ar_bill_status = 'BILLED' WHERE COALESCE(o.ar_bill_status, 'UNBILLED') <> 'BILLED'; diff --git a/bms-back/sql/ar/ar_bill_source_id_rename.sql b/bms-back/sql/ar/ar_bill_source_id_rename.sql new file mode 100644 index 00000000..7a0000cf --- /dev/null +++ b/bms-back/sql/ar/ar_bill_source_id_rename.sql @@ -0,0 +1,95 @@ +-- 应收账单来源ID字段命名迁移 +-- +-- 将历史字段名迁移为与当前来源表一致的名称: +-- ar_bill_detail.prc_bill_id -> prc_order_bill_id +-- ar_bill_detail_fee.prc_fee_id -> prc_income_bill_fee_id +-- 脚本可重复执行,已完成迁移的字段和索引会自动跳过。 +SET NAMES utf8mb4; +SET @db_name := DATABASE(); + +-- 1. 应收账单订单明细:来源计费订单ID。 +SET @has_old_detail_column := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail' + AND COLUMN_NAME = 'prc_bill_id' +); +SET @has_new_detail_column := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail' + AND COLUMN_NAME = 'prc_order_bill_id' +); +SET @sql := IF(@has_old_detail_column > 0 AND @has_new_detail_column = 0, + 'ALTER TABLE ar_bill_detail CHANGE COLUMN prc_bill_id prc_order_bill_id VARCHAR(36) NOT NULL COMMENT ''来源计费订单ID,对应 prc_order_bill.bill_id''', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. 应收账单费用明细:来源收入费用ID。 +SET @has_old_fee_column := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail_fee' + AND COLUMN_NAME = 'prc_fee_id' +); +SET @has_new_fee_column := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail_fee' + AND COLUMN_NAME = 'prc_income_bill_fee_id' +); +SET @sql := IF(@has_old_fee_column > 0 AND @has_new_fee_column = 0, + 'ALTER TABLE ar_bill_detail_fee CHANGE COLUMN prc_fee_id prc_income_bill_fee_id VARCHAR(36) NOT NULL COMMENT ''来源收入费用ID,对应 prc_income_bill_fee.fee_id''', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3. 费用明细索引同步改名,避免数据库中继续保留旧字段命名。 +SET @has_old_fee_unique := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail_fee' + AND INDEX_NAME = 'uk_ar_detail_prc_fee' +); +SET @has_new_fee_unique := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail_fee' + AND INDEX_NAME = 'uk_ar_detail_prc_income_fee' +); +SET @sql := IF(@has_old_fee_unique > 0 AND @has_new_fee_unique = 0, + 'ALTER TABLE ar_bill_detail_fee RENAME INDEX uk_ar_detail_prc_fee TO uk_ar_detail_prc_income_fee', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @has_old_fee_index := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail_fee' + AND INDEX_NAME = 'idx_ar_detail_prc_fee' +); +SET @has_new_fee_index := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'ar_bill_detail_fee' + AND INDEX_NAME = 'idx_ar_detail_prc_income_fee' +); +SET @sql := IF(@has_old_fee_index > 0 AND @has_new_fee_index = 0, + 'ALTER TABLE ar_bill_detail_fee RENAME INDEX idx_ar_detail_prc_fee TO idx_ar_detail_prc_income_fee', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-front/src/api/customersuppliermanagement/customerinfo/customerPriceSet.js b/bms-front/src/api/customersuppliermanagement/customerinfo/customerPriceSet.js new file mode 100644 index 00000000..d9de56fe --- /dev/null +++ b/bms-front/src/api/customersuppliermanagement/customerinfo/customerPriceSet.js @@ -0,0 +1,36 @@ +import request from '@/utils/request.js' + +// 查询客户已绑定的套餐列表 +export function listCustomerPriceSet(customerId) { + return request({ + url: '/customersupplier/customerinfo/priceset/bind/list', + method: 'get', + params: { customerId } + }) +} + +// 查询可绑定的生效套餐选项 +export function listPriceSetOption(query) { + return request({ + url: '/customersupplier/customerinfo/priceset/option/list', + method: 'get', + params: query + }) +} + +// 保存客户套餐绑定(已绑定则更新/更换,未绑定则新增) +export function saveCustomerPriceSet(data) { + return request({ + url: '/customersupplier/customerinfo/priceset/bind', + method: 'post', + data + }) +} + +// 解绑客户套餐 +export function delCustomerPriceSet(customerPricesetIds) { + return request({ + url: '/customersupplier/customerinfo/priceset/bind/' + customerPricesetIds, + method: 'delete' + }) +} diff --git a/bms-front/src/api/customersuppliermanagement/customermoney/customerbill.js b/bms-front/src/api/customersuppliermanagement/customermoney/customerbill.js new file mode 100644 index 00000000..be8ed613 --- /dev/null +++ b/bms-front/src/api/customersuppliermanagement/customermoney/customerbill.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +/** 查询客户可核销账单(已确认/部分核销) */ +export function listUnsettledBill(customerId) { + return request({ + url: '/customersupplier/customerbill/unsettled/' + customerId, + method: 'get' + }) +} + +/** 查询余额核销记录 */ +export function listBalanceWriteoff(query) { + return request({ + url: '/customersupplier/customerbill/writeoff/list', + method: 'get', + params: query + }) +} + +/** 用客户账户余额核销账单 */ +export function balanceWriteoff(data) { + return request({ + url: '/customersupplier/customerbill/writeoff', + method: 'post', + data: data + }) +} + +/** 反核销(取消一笔有效余额核销) */ +export function cancelBalanceWriteoff(writeoffId, cancelRemark) { + return request({ + url: '/customersupplier/customerbill/writeoff/' + writeoffId + '/cancel', + method: 'put', + data: { cancelRemark } + }) +} + +/** 账单查看:账单列表(只读) */ +export function listCustomerBill(query) { + return request({ + url: '/customersupplier/customerbill/list', + method: 'get', + params: query + }) +} + +/** 账单查看:账单详情(含明细) */ +export function getCustomerBill(arBillId) { + return request({ + url: '/customersupplier/customerbill/' + arBillId, + method: 'get' + }) +} diff --git a/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js b/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js index cf1a46e3..a9847d8e 100644 --- a/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js +++ b/bms-front/src/api/customersuppliermanagement/customermoney/customermoney.js @@ -58,3 +58,21 @@ export function listFlow(query) { params: query }) } + +/** 查询额度流水列表 */ +export function listCreditFlow(query) { + return request({ + url: '/customersupplier/customermoney/credit/flow/list', + method: 'get', + params: query + }) +} + +/** 调整客户额度(信用额度/临时额度),调整过程留额度流水 */ +export function adjustCredit(data) { + return request({ + url: '/customersupplier/customermoney/credit/adjust', + method: 'post', + data: data + }) +} diff --git a/bms-front/src/views/ar/bill/index.vue b/bms-front/src/views/ar/bill/index.vue index 1d4d417a..8210bd08 100644 --- a/bms-front/src/views/ar/bill/index.vue +++ b/bms-front/src/views/ar/bill/index.vue @@ -1,9 +1,12 @@ + + + + + + + + + + +
+ + {{ setTypeMap[selectedPriceSet.setType] || selectedPriceSet.setType }} + + + 套餐有效期:{{ selectedPriceSet.effectiveDate || '-' }} 至 {{ selectedPriceSet.expireDate || '长期' }} + +
+
+ + +
数字越小优先级越高,1 为最高
+
+ + + + + + + + + + + + + + + +
+ +
@@ -582,6 +703,10 @@ import { listCustomer, getCustomer, addCustomer, updateCustomer, delCustomerCompany, delCustomerContact } from '@/api/customersuppliermanagement/customerinfo/customer.js' +import { + listCustomerPriceSet, listPriceSetOption, + saveCustomerPriceSet, delCustomerPriceSet +} from '@/api/customersuppliermanagement/customerinfo/customerPriceSet.js' import { getToken } from '@/utils/auth' import axios from 'axios' import { saveAs } from 'file-saver' @@ -630,7 +755,7 @@ function buildEmptyForm() { foreignCurrencyBalance: null, weightThreshold: null, throwRatio: null, - creditEd: null, + creditEd: 1000, productIds: null, attachmentList: [], companyList: [], @@ -656,6 +781,99 @@ function removeProduct(productId) { form.value.productIds = ids.join(',') } +/* ---------------- 套餐绑定(prc_customer_price_set,一客户一套餐) ---------------- */ +const setTypeMap = { FIXED: '固定价', DISCOUNT: '折扣' } +const priceSetStatusMap = { D: '草稿', A: '审核中', E: '生效', R: '驳回', S: '停用' } +const priceSetStatusTagType = { D: 'info', A: 'warning', E: 'success', R: 'danger', S: 'info' } + +const boundPriceSets = ref([]) +const priceSetOptions = ref([]) +const priceSetDialogVisible = ref(false) +const priceSetDialogTitle = ref('') +const priceSetSaving = ref(false) +const priceSetRef = ref() +const priceSetForm = ref({}) + +const priceSetRules = { + priceSetId: [{ required: true, message: '请选择价格套餐', trigger: 'change' }], + bindPriority: [{ required: true, message: '优先级不能为空', trigger: 'blur' }], + prBegindate: [{ required: true, message: '绑定生效日期不能为空', trigger: 'change' }] +} + +const selectedPriceSet = computed(() => + priceSetOptions.value.find(o => o.priceSetId === priceSetForm.value.priceSetId) +) + +/** 加载当前客户已绑定套餐 */ +function loadBoundPriceSets() { + if (!form.value.customerId) { + boundPriceSets.value = [] + return + } + listCustomerPriceSet(form.value.customerId).then(res => { + boundPriceSets.value = res.data || [] + }) +} + +/** 加载可绑定的生效套餐(仅加载一次) */ +function ensurePriceSetOptions() { + if (priceSetOptions.value.length) return + listPriceSetOption().then(res => { + priceSetOptions.value = res.data || [] + }) +} + +/** 打开绑定/更换套餐弹窗:row 为空=新增绑定,有值=更换 */ +function openPriceSetDialog(row) { + if (!form.value.customerId) { + proxy.$modal.msgWarning('请先保存客户后再绑定套餐') + return + } + ensurePriceSetOptions() + priceSetForm.value = row + ? { ...row } + : { priceSetId: null, bindPriority: 1, prBegindate: null, prEnddate: null, priceSetNote: null } + priceSetDialogTitle.value = row || boundPriceSets.value.length ? '更换套餐' : '绑定套餐' + priceSetDialogVisible.value = true + nextTick(() => proxy.$refs['priceSetRef'] && proxy.$refs['priceSetRef'].clearValidate()) +} + +/** 选择套餐后,生效日期未填时默认带出套餐生效日期 */ +function onPriceSetChange(priceSetId) { + const opt = priceSetOptions.value.find(o => o.priceSetId === priceSetId) + if (opt && !priceSetForm.value.prBegindate) { + priceSetForm.value.prBegindate = opt.effectiveDate || null + } +} + +function submitPriceSetForm() { + proxy.$refs['priceSetRef'].validate(valid => { + if (!valid) return + if (priceSetForm.value.prEnddate && priceSetForm.value.prBegindate > priceSetForm.value.prEnddate) { + proxy.$modal.msgWarning('失效日期不能早于生效日期') + return + } + const payload = { ...priceSetForm.value, customerId: form.value.customerId } + priceSetSaving.value = true + saveCustomerPriceSet(payload).then(() => { + proxy.$modal.msgSuccess('保存成功') + priceSetDialogVisible.value = false + loadBoundPriceSets() + }).finally(() => { + priceSetSaving.value = false + }) + }) +} + +function handleUnbindPriceSet(row) { + proxy.$modal.confirm(`确认解绑套餐「${row.priceSetName || row.priceSetCode || ''}」?解绑后该客户询价将不再命中此套餐。`) + .then(() => delCustomerPriceSet(row.customerPricesetId)) + .then(() => { + proxy.$modal.msgSuccess('解绑成功') + loadBoundPriceSets() + }).catch(() => {}) +} + /* ---------------- 用户冗余姓名 ---------------- */ function handleUserChange(uid, user, nameField) { form.value[nameField] = user ? (user.nickName || user.userName || null) : null @@ -968,6 +1186,7 @@ function loadDetail(id) { form.value.attachmentList = form.value.attachmentList || [] form.value.companyList = form.value.companyList || [] form.value.contactList = form.value.contactList || [] + loadBoundPriceSets() }) } @@ -1023,6 +1242,7 @@ function initByRoute() { loadDetail(customerId.value).finally(() => { pageLoading.value = false }) } else { form.value = buildEmptyForm() + boundPriceSets.value = [] } } @@ -1077,4 +1297,14 @@ onMounted(() => { .bound-table { margin-top: 4px; } +.ps-readonly { + display: flex; + align-items: center; + gap: 10px; + color: #606266; + font-size: 13px; + &__range { + color: #909399; + } +} diff --git a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue index 82047fc9..c6e779e8 100644 --- a/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue +++ b/bms-front/src/views/customersuppliermanagement/customerinfo/customermoney/index.vue @@ -11,6 +11,17 @@
客户编码:{{ customer.customerCode || route.query.customerCode || '-' }}
本位币账户 CNY + 查看账单 + 额度调整
@@ -31,14 +42,23 @@ -
总额度
+
+ 总额度 + 调整 +
{{ formatMoney(currentAccount.totalCredit) }}
-
额外额度
+
+ 额外额度 + 调整 +
{{ formatMoney(currentAccount.extraCredit) }}
+
{{ tempCreditPeriodText }}
@@ -225,6 +245,204 @@ @pagination="getFlowList" /> + + + + + + + + + + + + + + + + + + + 搜索 + 重置 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 待核销账单:{{ unsettledList.length }} 张 + 未核销总额:CNY {{ unsettledTotal }} + 账户余额:CNY {{ formatMoney(currentAccount.balance) }} + 刷新 + 查看全部账单 +
+
+ +
待核销账单(已确认 / 部分核销)
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
余额核销记录
+ + + + + + + + + 搜索 + 重置 + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -402,6 +620,183 @@ 关 闭 + + + + +
当前{{ creditDialog.form.creditType === 'TEMP' ? '临时额度' : '信用额度' }}: + {{ formatMoney(creditBefore) }},已用额度:{{ formatMoney(currentAccount.usedCredit) }}
+
+ + + + + + 信用额度(长期) + 临时额度 + + + + + + + 设置为 + 增加 + 减少 + + + + + + + + + + + + + + + + + +
+
调整后{{ creditDialog.form.creditType === 'TEMP' ? '临时额度' : '信用额度' }}: + {{ formatMoney(creditAfter) }} +
+
调整后授信总额:{{ formatMoney(totalCreditAfter) }} + (可用 = 授信总额 - 已用 {{ formatMoney(currentAccount.usedCredit) }})
+ + +
+ + +
+ + + + + {{ woDialog.bill.billNo }} + {{ woDialog.bill.billName || '-' }} + + CNY {{ formatMoney(woDialog.bill.unwrittenAmount) }} + + + CNY {{ formatMoney(currentAccount.balance) }} + + + + + + CNY + + + + + + + + + + + + + + + + + + + 反核销后,核销金额 CNY {{ formatMoney(cancelWoDialog.row.writeoffAmount) }} 将退回客户账户余额, + 账单「{{ cancelWoDialog.row.billNo }}」恢复为未核销,已用额度同步加回。 + + + + + + + + + @@ -409,11 +804,13 @@ import axios from 'axios' import { saveAs } from 'file-saver' import { getCustomer } from "@/api/customersuppliermanagement/customerinfo/customer.js" -import { listAccount, listReceipt, getReceipt, addReceipt, confirmReceipt, cancelReceipt, listFlow } from "@/api/customersuppliermanagement/customermoney/customermoney.js" +import { listAccount, listReceipt, getReceipt, addReceipt, confirmReceipt, cancelReceipt, listFlow, listCreditFlow, adjustCredit } from "@/api/customersuppliermanagement/customermoney/customermoney.js" +import { listUnsettledBill, listBalanceWriteoff, balanceWriteoff, cancelBalanceWriteoff } from "@/api/customersuppliermanagement/customermoney/customerbill.js" import { convertToBase } from "@/api/data/exchangeRate" import { getToken } from "@/utils/auth" import { useRoute } from 'vue-router' +import { nextTick } from 'vue' const route = useRoute() const router = useRouter() @@ -472,9 +869,28 @@ function receiptTypeTag(v) { function receiptStatusTag(v) { return { PENDING: 'warning', CONFIRMED: 'success', CANCELLED: 'danger' }[v] || 'info' } -function bizTypeTag(v) { +const bizTypeTag = function(v) { return { RECEIPT: 'success', BILL_PAY: 'danger', REFUND: 'warning', ADJUST: 'info', FREEZE: 'info', UNFREEZE: '' }[v] || 'info' } + +/* ================= 额度相关枚举 ================= */ +const creditTypeOptions = [ + { value: 'CREDIT', label: '信用额度' }, + { value: 'TEMP', label: '临时额度' } +] +const creditChangeTypeOptions = [ + { value: 'INIT', label: '初始化' }, + { value: 'ADJUST', label: '手工调整' }, + { value: 'EXPIRE', label: '到期失效' } +] +const adjustModeOptions = [ + { value: 'SET', label: '设置为' }, + { value: 'ADD', label: '增加' }, + { value: 'SUB', label: '减少' } +] +function creditChangeTag(v) { + return { INIT: '', ADJUST: 'primary', EXPIRE: 'danger' }[v] || 'info' +} function formatMoney(val) { const num = Number(val) return isNaN(num) ? '0.00' : num.toFixed(2) @@ -506,6 +922,152 @@ const availableCredit = computed(() => { return formatMoney(total + extra - used) }) +/** 临时额度有效期文案 */ +const tempCreditPeriodText = computed(() => { + const a = currentAccount.value + const extra = Number(a.extraCredit) || 0 + if (extra <= 0) return '' + if (!a.extraExpireDate) return '长期有效' + const today = proxy.parseTime(new Date(), '{y}-{m}-{d}') + if (a.extraExpireDate < today) return '已过期' + return '有效期至 ' + a.extraExpireDate +}) + +/* ============ 额度调整弹窗 ============ */ +const creditFormRef = ref(null) +const creditSubmitLoading = ref(false) +const creditDialog = reactive({ + visible: false, + form: {} +}) +const creditRules = { + creditType: [{ required: true, message: '请选择额度类型', trigger: 'change' }], + adjustMode: [{ required: true, message: '请选择调整方式', trigger: 'change' }], + changeAmount: [{ required: true, message: '调整金额必须大于0', trigger: 'blur' }], + reason: [{ required: true, message: '请填写调整原因', trigger: 'blur' }] +} + +/** 禁止选择晚于今天的日期(不允许预约未来生效) */ +function afterToday(date) { + const today = new Date() + today.setHours(23, 59, 59, 999) + return date.getTime() > today.getTime() +} + +function defaultCreditForm(creditType) { + return { + customerId: route.query.customerId || null, + creditType: creditType || 'CREDIT', + adjustMode: 'ADD', + changeAmount: null, + reason: '', + effectiveDate: proxy.parseTime(new Date(), '{y}-{m}-{d}'), + expireDate: '' + } +} + +function openAdjustDialog(creditType) { + const customerId = Number(route.query.customerId) + if (!customerId) { + proxy.$modal.msgError('缺少客户ID,请从客户列表进入客户余额页') + return + } + resetCreditForm() + creditDialog.form = defaultCreditForm(creditType) + creditDialog.visible = true +} + +function resetCreditForm() { + creditDialog.form = defaultCreditForm() + proxy.resetForm('creditFormRef') +} + +/** 切换额度类型:清掉校验红框,临时额度默认生效日为今天 */ +function onCreditTypeChange() { + creditDialog.form.changeAmount = null + if (creditDialog.form.creditType === 'TEMP' && !creditDialog.form.effectiveDate) { + creditDialog.form.effectiveDate = proxy.parseTime(new Date(), '{y}-{m}-{d}') + } + nextTick(() => proxy.clearValidate('creditFormRef')) +} + +/** 当前被调整额度(调整前) */ +const creditBefore = computed(() => { + const a = currentAccount.value + return Number(creditDialog.form.creditType === 'TEMP' ? a.extraCredit : a.totalCredit) || 0 +}) + +/** 调整后被调额度 */ +const creditAfter = computed(() => { + const input = Number(creditDialog.form.changeAmount) || 0 + const mode = creditDialog.form.adjustMode + if (mode === 'SET') return input + return mode === 'SUB' ? creditBefore.value - input : creditBefore.value + input +}) + +/** 调整后授信总额(信用+临时) */ +const totalCreditAfter = computed(() => { + const a = currentAccount.value + const other = Number(creditDialog.form.creditType === 'TEMP' ? a.totalCredit : a.extraCredit) || 0 + return other + creditAfter.value +}) + +function submitAdjustCredit() { + creditFormRef.value.validate(valid => { + if (!valid) return + const f = creditDialog.form + const amount = Number(f.changeAmount) + if (!(amount > 0)) { + proxy.$modal.msgError('调整金额必须大于0') + return + } + if (f.creditType === 'TEMP') { + if (f.expireDate && f.effectiveDate && f.expireDate <= f.effectiveDate) { + proxy.$modal.msgError('失效日期必须晚于生效日期') + return + } + } + if (creditAfter.value < 0) { + proxy.$modal.msgError('调整后额度不能为负数') + return + } + const used = Number(currentAccount.value.usedCredit) || 0 + if (totalCreditAfter.value < used) { + proxy.$modal.msgError('调整后授信总额不能小于已用额度 ' + formatMoney(used)) + return + } + if (creditAfter.value === creditBefore.value) { + proxy.$modal.msgError('额度未发生变化,无需调整') + return + } + const payload = { + customerId: Number(f.customerId) || Number(route.query.customerId) || null, + creditType: f.creditType, + adjustMode: f.adjustMode, + changeAmount: amount, + reason: f.reason + } + if (!payload.customerId) { + proxy.$modal.msgError('缺少客户ID,请从客户列表进入客户余额页') + return + } + if (f.creditType === 'TEMP') { + payload.effectiveDate = f.effectiveDate || null + payload.expireDate = f.expireDate || null + } + creditSubmitLoading.value = true + adjustCredit(payload).then(() => { + proxy.$modal.msgSuccess('额度调整成功') + creditDialog.visible = false + loadCustomer() + loadAccounts() + getCreditFlowList() + }).finally(() => { + creditSubmitLoading.value = false + }) + }) +} + function loadCustomer() { const customerId = route.query.customerId if (!customerId) return @@ -752,6 +1314,208 @@ function resetFlowQuery() { getFlowList() } +/* ================= 额度流水 ================= */ +const creditFlowList = ref([]) +const creditFlowTotal = ref(0) +const creditFlowLoading = ref(false) +const creditFlowDateRange = ref([]) +const creditFlowQuery = reactive({ + pageNum: 1, + pageSize: 10, + customerId: route.query.customerId, + creditType: '', + changeType: '' +}) + +function getCreditFlowList() { + creditFlowLoading.value = true + const params = proxy.addDateRange({ ...creditFlowQuery }, creditFlowDateRange.value) + listCreditFlow(params).then(res => { + creditFlowList.value = res.rows || [] + creditFlowTotal.value = res.total || 0 + }).finally(() => { + creditFlowLoading.value = false + }) +} + +function resetCreditFlowQuery() { + creditFlowQuery.creditType = '' + creditFlowQuery.changeType = '' + creditFlowDateRange.value = [] + creditFlowQuery.pageNum = 1 + getCreditFlowList() +} + +/* ================= 账单核销 ================= */ +const billStatusMap = { + DRAFT: { label: '草稿', type: 'info' }, + CONFIRMED: { label: '已确认', type: 'warning' }, + PARTIAL_WRITTEN_OFF: { label: '部分核销', type: 'primary' }, + WRITTEN_OFF: { label: '已核销', type: 'success' }, + VOID: { label: '已作废', type: 'danger' } +} +function billStatusLabel(v) { + return billStatusMap[v]?.label || v || '-' +} +function billStatusTag(v) { + return billStatusMap[v]?.type || 'info' +} +function formatDate(val) { + if (!val) return '-' + return String(val).substring(0, 10) +} +function goBillView() { + router.push({ + path: '/customersuppliermanagement/customerbill', + query: { customerId: route.query.customerId || '' } + }) +} + +const unsettledList = ref([]) +const unsettledLoading = ref(false) +/** 未核销账单合计 */ +const unsettledTotal = computed(() => { + const sum = unsettledList.value.reduce((s, r) => s + (Number(r.unwrittenAmount) || 0), 0) + return formatMoney(sum) +}) + +const writeoffList = ref([]) +const writeoffTotal = ref(0) +const writeoffLoading = ref(false) +const woQuery = reactive({ + pageNum: 1, + pageSize: 10, + customerId: route.query.customerId, + status: '' +}) +let billWriteoffLoaded = false + +function loadUnsettledBills() { + const customerId = route.query.customerId + if (!customerId) return + unsettledLoading.value = true + listUnsettledBill(customerId).then(res => { + unsettledList.value = res.data || [] + }).finally(() => { + unsettledLoading.value = false + }) +} + +function getWriteoffList() { + const customerId = route.query.customerId + if (!customerId) return + writeoffLoading.value = true + woQuery.customerId = customerId + listBalanceWriteoff({ ...woQuery }).then(res => { + writeoffList.value = res.rows || [] + writeoffTotal.value = res.total || 0 + }).finally(() => { + writeoffLoading.value = false + }) +} + +function resetWriteoffQuery() { + woQuery.status = '' + woQuery.pageNum = 1 + getWriteoffList() +} + +function loadBillWriteoffData() { + loadAccounts() + loadUnsettledBills() + getWriteoffList() +} + +/* ---- 核销弹窗 ---- */ +const woSubmitLoading = ref(false) +const woDialog = reactive({ visible: false, bill: null, form: {} }) +/** 核销后余额预览(不为负则可提交) */ +const woAfterBalance = computed(() => { + const balance = Number(currentAccount.value.balance) || 0 + const amount = Number(woDialog.form.writeoffAmount) || 0 + return formatMoney(balance - amount) +}) + +function openWriteoffDialog(row) { + // 先刷新账户余额,避免用过期余额核销 + listAccount(route.query.customerId).then(res => { + accountList.value = res.data || [] + }) + woDialog.bill = row + const balance = Number(currentAccount.value.balance) || 0 + const unwritten = Number(row.unwrittenAmount) || 0 + woDialog.form = { + arBillId: row.arBillId, + customerId: Number(route.query.customerId), + writeoffAmount: Math.min(unwritten, balance > 0 ? balance : unwritten), + writeoffDate: proxy.parseTime(new Date(), '{y}-{m}-{d}'), + remark: '' + } + woDialog.visible = true +} + +function submitWriteoff() { + const bill = woDialog.bill + const amount = Number(woDialog.form.writeoffAmount) + if (!(amount > 0)) { + proxy.$modal.msgError('核销金额必须大于0') + return + } + const unwritten = Number(bill.unwrittenAmount) || 0 + if (amount > unwritten) { + proxy.$modal.msgError('核销金额不能超过账单未核销金额 ' + formatMoney(unwritten)) + return + } + const balance = Number(currentAccount.value.balance) || 0 + if (amount > balance) { + proxy.$modal.msgError('账户余额不足,当前余额 CNY ' + formatMoney(balance) + ',请先登记收款并审核') + return + } + woSubmitLoading.value = true + balanceWriteoff(woDialog.form).then(() => { + proxy.$modal.msgSuccess('核销成功:账单已核销 CNY ' + formatMoney(amount)) + woDialog.visible = false + loadBillWriteoffData() + getFlowList() + }).finally(() => { + woSubmitLoading.value = false + }) +} + +/* ---- 反核销弹窗 ---- */ +const cancelWoLoading = ref(false) +const cancelWoDialog = reactive({ visible: false, row: null, cancelRemark: '' }) + +function openCancelWriteoffDialog(row) { + cancelWoDialog.row = row + cancelWoDialog.cancelRemark = '' + cancelWoDialog.visible = true +} + +function submitCancelWriteoff() { + const remark = cancelWoDialog.cancelRemark?.trim() + if (!remark) { + proxy.$modal.msgError('请填写反核销原因') + return + } + cancelWoLoading.value = true + cancelBalanceWriteoff(cancelWoDialog.row.writeoffId, remark).then(() => { + proxy.$modal.msgSuccess('反核销成功,余额已退回') + cancelWoDialog.visible = false + loadBillWriteoffData() + getFlowList() + }).finally(() => { + cancelWoLoading.value = false + }) +} + +watch(activeTab, (tab) => { + if (tab === 'billWriteoff' && !billWriteoffLoaded) { + billWriteoffLoaded = true + loadBillWriteoffData() + } +}) + /* ================= 附件上传 ================= */ const uploadFileUrl = ref(baseUrl + "/common/upload") const uploadHeaders = ref({ Authorization: "Bearer " + getToken() }) @@ -848,6 +1612,8 @@ function initPage() { viewDialog.attachments = [] receiptDateRange.value = [] flowDateRange.value = [] + creditFlowDateRange.value = [] + creditDialog.visible = false receiptQuery.customerId = customerId receiptQuery.receiptType = '' receiptQuery.status = '' @@ -856,15 +1622,35 @@ function initPage() { flowQuery.bizType = '' flowQuery.direction = '' flowQuery.pageNum = 1 + creditFlowQuery.customerId = customerId + creditFlowQuery.creditType = '' + creditFlowQuery.changeType = '' + creditFlowQuery.pageNum = 1 receiptList.value = [] receiptTotal.value = 0 flowList.value = [] flowTotal.value = 0 + creditFlowList.value = [] + creditFlowTotal.value = 0 + unsettledList.value = [] + writeoffList.value = [] + writeoffTotal.value = 0 + woQuery.customerId = customerId + woQuery.status = '' + woQuery.pageNum = 1 + woDialog.visible = false + cancelWoDialog.visible = false + billWriteoffLoaded = false if (!customerId) return loadCustomer() loadAccounts() getReceiptList() getFlowList() + getCreditFlowList() + if (activeTab.value === 'billWriteoff') { + billWriteoffLoaded = true + loadBillWriteoffData() + } } onMounted(initPage) @@ -916,6 +1702,31 @@ watch(() => route.query.customerId, (newId, oldId) => { color: var(--el-text-color-secondary); font-size: 13px; margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.balance-sub { + margin-top: 6px; + font-size: 12px; + color: var(--el-text-color-secondary); + min-height: 16px; +} +.credit-tip { + margin-bottom: 12px; +} +.credit-preview { + margin-top: 4px; + padding: 10px 14px; + background: var(--el-fill-color-light); + border-radius: 4px; + font-size: 13px; + line-height: 1.8; +} +.credit-alert { + margin-top: 8px; + margin-bottom: 0; } .balance-value { font-size: 22px; @@ -952,4 +1763,28 @@ watch(() => route.query.customerId, (newId, oldId) => { font-weight: 600; margin: 12px 0 8px; } +.writeoff-summary { + margin-bottom: 12px; +} +.wo-summary { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + font-size: 13px; +} +.wo-section-title { + font-weight: 600; + font-size: 14px; + margin: 14px 0 8px; + padding-left: 8px; + border-left: 3px solid var(--el-color-primary); +} +.wo-form { + margin-top: 14px; +} +.wo-form-unit { + margin-left: 8px; + color: var(--el-text-color-secondary); +}