新增、编辑、删除费用后,都会根据费用明细重新汇总订单状态:

- 全部未出账:UNBILLED
- 部分已出账、部分未出账:PARTIAL
- 全部已出账:BILLED
3. 应收账单生成
   - 预览、统计、分页生成支持 UNBILLED 和 PARTIAL;
   - PARTIAL 订单只读取未出账费用;
   - 生成成功后,只将本次实际生成的费用更新为 BILLED;
   - 不会重复带入历史已出账费用;
   - 账单金额只统计本次未出账费用。
4. 作废、取消、失败清理
   - 只恢复当前账单关联的费用;
   - 如果费用仍被其他有效账单使用,则继续保持 BILLED;
   - 根据费用状态重新汇总订单状态;
   - 大账单清理使用批量 SQL,不组装几十万条费用 ID。
This commit is contained in:
caojia
2026-09-22 17:20:27 +08:00
parent cb50d2a258
commit 221d4721ee
11 changed files with 457 additions and 106 deletions
@@ -0,0 +1,20 @@
package com.ruoyi.ar.enums;
/** 应收费用明细是否已经生成正式应收账单。 */
public enum ArIncomeBillFeeStatus
{
UNBILLED("UNBILLED"),
BILLED("BILLED");
private final String code;
ArIncomeBillFeeStatus(String code)
{
this.code = code;
}
public String getCode()
{
return code;
}
}
@@ -1,9 +1,10 @@
package com.ruoyi.ar.enums;
/** 计费订单的应收账单占用状态。 */
/** 计费订单的应收账单状态。 */
public enum ArOrderBillStatus
{
UNBILLED("UNBILLED"),
PARTIAL("PARTIAL"),
BILLED("BILLED");
private final String code;
@@ -13,6 +13,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
@@ -51,6 +52,7 @@ import com.ruoyi.ar.enums.ArBillGenerationStatus;
import com.ruoyi.ar.enums.ArBillSelectionMode;
import com.ruoyi.ar.enums.ArBillStatus;
import com.ruoyi.ar.enums.ArExportStatus;
import com.ruoyi.ar.enums.ArIncomeBillFeeStatus;
import com.ruoyi.ar.enums.ArLogObjectType;
import com.ruoyi.ar.enums.ArLogOperationSource;
import com.ruoyi.ar.enums.ArLogOperationType;
@@ -222,7 +224,7 @@ public class ArBillServiceImpl implements IArBillService {
return sourceBillMapper.selectPrcOrderBillByCustomerPeriod(query.getCustomerId(),
query.getCustomerCode(), StringUtils.trim(query.getOrderOrTrackingNo()),
StringUtils.trim(query.getExcludeOrderOrTrackingNo()), query.getBillPeriodEnd(), query.getCurrency(),
ArOrderBillStatus.UNBILLED.getCode(), null);
eligibleOrderBillStatuses(), ArIncomeBillFeeStatus.UNBILLED.getCode(), null);
}
/**
@@ -234,7 +236,7 @@ public class ArBillServiceImpl implements IArBillService {
return nvl(sourceBillMapper.sumPrcOrderBillIncomeByCustomerPeriod(query.getCustomerId(),
query.getCustomerCode(), StringUtils.trim(query.getOrderOrTrackingNo()),
StringUtils.trim(query.getExcludeOrderOrTrackingNo()), query.getBillPeriodEnd(), query.getCurrency(),
ArOrderBillStatus.UNBILLED.getCode()));
eligibleOrderBillStatuses(), ArIncomeBillFeeStatus.UNBILLED.getCode()));
}
/**
@@ -421,7 +423,8 @@ public class ArBillServiceImpl implements IArBillService {
log.info("开始统计应收账单生成范围,taskId={}", taskId);
int totalCount = sourceBillMapper.countPrcOrderBillByCustomerPeriod(
request.getCustomerId(), request.getCustomerCode(), StringUtils.trim(request.getOrderOrTrackingNo()),
request.getBillPeriodEnd(), request.getCurrency(), ArOrderBillStatus.UNBILLED.getCode(),
request.getBillPeriodEnd(), request.getCurrency(), eligibleOrderBillStatuses(),
ArIncomeBillFeeStatus.UNBILLED.getCode(),
task.getSnapshotCreateTime(), null, excludedSourceBillIds);
if (totalCount <= 0) {
throw new ServiceException("当前全选范围内没有可生成的订单");
@@ -545,7 +548,8 @@ public class ArBillServiceImpl implements IArBillService {
List<PrcOrderBill> sources = sourceBillMapper.selectPrcOrderBillByCustomerPeriodCursor(
request.getCustomerId(), request.getCustomerCode(), StringUtils.trim(request.getOrderOrTrackingNo()),
request.getBillPeriodEnd(), StringUtils.isEmpty(request.getCurrency()) ? "CNY" : request.getCurrency(),
ArOrderBillStatus.UNBILLED.getCode(), task.getSnapshotCreateTime(), task.getLastSourceBillId(),
eligibleOrderBillStatuses(), ArIncomeBillFeeStatus.UNBILLED.getCode(),
task.getSnapshotCreateTime(), task.getLastSourceBillId(),
explicitIds, selectionMode == ArBillSelectionMode.ALL_MATCHED ? excludedSourceBillIds : null,
GENERATE_PAGE_SIZE);
if (sources == null || sources.isEmpty()) {
@@ -563,13 +567,36 @@ public class ArBillServiceImpl implements IArBillService {
throw new ServiceException("截止日期内存在多个币种,不能合并为一张账单");
}
}
// 先抓取本批次的未出账费用快照,后续只更新和复制这些费用,避免把同订单已有 BILLED 费用重复带入新账单。
List<PrcIncomeBillFee> sourceFees = sourceFeeMapper.selectUnbilledByBillIds(
currentSourceBillIds, ArIncomeBillFeeStatus.UNBILLED.getCode());
if (sourceFees == null || sourceFees.isEmpty()) {
throw new ServiceException("当前批次没有可生成的未出账费用");
}
Map<String, Integer> sourceFeeCounts = new HashMap<>();
List<String> sourceFeeIds = new ArrayList<>(sourceFees.size());
for (PrcIncomeBillFee sourceFee : sourceFees) {
sourceFeeCounts.merge(sourceFee.getBillId(), 1, Integer::sum);
sourceFeeIds.add(sourceFee.getFeeId());
}
for (String sourceBillId : currentSourceBillIds) {
if (!sourceFeeCounts.containsKey(sourceBillId)) {
throw new ServiceException("来源订单没有可生成的未出账费用:" + sourceBillId);
}
}
int claimedCount = sourceBillMapper.claimArBillOrders(currentSourceBillIds, task.getCreateBy(),
ArOrderBillStatus.UNBILLED.getCode(), ArOrderBillStatus.BILLED.getCode());
eligibleOrderBillStatuses(), ArOrderBillStatus.BILLED.getCode());
if (claimedCount != currentSourceBillIds.size()) {
throw new ServiceException("部分计费订单已被其他账单占用,请重新查询后生成");
}
sourceFeeMapper.updateArBillStatusByBillIds(currentSourceBillIds,
ArOrderBillStatus.UNBILLED.getCode(), ArOrderBillStatus.BILLED.getCode());
int billedFeeCount = sourceFeeMapper.updateArBillStatusByFeeIds(sourceFeeIds,
ArIncomeBillFeeStatus.UNBILLED.getCode(), ArIncomeBillFeeStatus.BILLED.getCode());
if (billedFeeCount != sourceFeeIds.size()) {
throw new ServiceException("部分费用状态已发生变化,请重新查询后生成");
}
sourceBillMapper.refreshArBillStatusByBillIds(currentSourceBillIds);
int detailNo = (task.getProcessedCount() == null ? 0 : task.getProcessedCount()) + 1;
List<ArBillDetail> details = new ArrayList<>(sources.size());
@@ -586,7 +613,6 @@ public class ArBillServiceImpl implements IArBillService {
throw new ServiceException("账单明细写入数量不一致");
}
List<PrcIncomeBillFee> sourceFees = sourceFeeMapper.selectByBillIds(currentSourceBillIds);
List<ArBillDetailFee> detailFees = new ArrayList<>(sourceFees == null ? 0 : sourceFees.size());
Map<String, Integer> feeNumbers = new HashMap<>();
if (sourceFees != null) {
@@ -625,18 +651,9 @@ public class ArBillServiceImpl implements IArBillService {
*/
private void cleanupGenerateTask(ArBillGenerateTask task, String message, boolean canceled,
String operator, String requestId) {
List<String> sourceBillIds = new ArrayList<>();
for (ArBillDetail detail : detailMapper.selectByBillId(task.getArBillId())) {
if (!StringUtils.isEmpty(detail.getPrcOrderBillId())) {
sourceBillIds.add(detail.getPrcOrderBillId());
}
}
sourceBillMapper.releaseArBillOrdersByArBillId(task.getArBillId(), operator,
ArOrderBillStatus.BILLED.getCode(), ArOrderBillStatus.UNBILLED.getCode());
if (!sourceBillIds.isEmpty()) {
sourceFeeMapper.updateArBillStatusByBillIds(sourceBillIds,
ArOrderBillStatus.BILLED.getCode(), ArOrderBillStatus.UNBILLED.getCode());
}
// 当前任务账单仍处于草稿状态,恢复时排除当前账单自身的关联,避免组装超大的来源 ID 集合。
sourceFeeMapper.restoreUnbilledIfNoActiveArBillByArBillId(task.getArBillId());
sourceBillMapper.refreshArBillStatusByArBillId(task.getArBillId());
detailFeeMapper.deleteByBillId(task.getArBillId());
detailMapper.deleteByBillId(task.getArBillId());
billMapper.updateArBillGenerationStatus(task.getArBillId(), ArBillGenerationStatus.FAILED, operator, message);
@@ -781,28 +798,14 @@ public class ArBillServiceImpl implements IArBillService {
bill.getStatus(),
"当前状态不允许作废", operator, requestId);
}
List<String> sourceBillIds = new ArrayList<>();
for (ArBillDetail detail : detailMapper.selectByBillId(arBillId)) {
if (!StringUtils.isEmpty(detail.getPrcOrderBillId())) {
sourceBillIds.add(detail.getPrcOrderBillId());
}
}
if (billMapper.updateArBillStatus(arBillId, ArBillStatus.VOID.getCode(), remark, operator, currentStatus.getCode()) != 1) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
bill.getStatus(),
"账单状态已发生变化,请刷新后重试", operator, requestId);
}
if (!sourceBillIds.isEmpty()) {
int releasedCount = sourceBillMapper.releaseArBillOrders(sourceBillIds, operator,
ArOrderBillStatus.BILLED.getCode(), ArOrderBillStatus.UNBILLED.getCode());
if (releasedCount != sourceBillIds.size()) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
bill.getStatus(),
"部分来源订单状态异常,账单未作废,请刷新后重试", operator, requestId);
}
sourceFeeMapper.updateArBillStatusByBillIds(sourceBillIds,
ArOrderBillStatus.BILLED.getCode(), ArOrderBillStatus.UNBILLED.getCode());
}
// 作废后保留历史明细,只释放本账单关联的费用;若费用仍被其他有效账单引用,则继续保持 BILLED。
sourceFeeMapper.restoreUnbilledIfNoActiveArBillByArBillId(arBillId);
sourceBillMapper.refreshArBillStatusByArBillId(arBillId);
recordSuccess(bill, ArLogObjectType.BILL, ArLogOperationType.VOID,
"作废应收账单:" + remark, bill.getStatus(), ArBillStatus.VOID.getCode(), requestId, operator);
return selectArBillById(arBillId);
@@ -1667,6 +1670,11 @@ public class ArBillServiceImpl implements IArBillService {
}
}
/** 可参与本次应收生成的订单状态;PARTIAL 只生成其中仍为 UNBILLED 的费用。 */
private List<String> eligibleOrderBillStatuses() {
return Arrays.asList(ArOrderBillStatus.UNBILLED.getCode(), ArOrderBillStatus.PARTIAL.getCode());
}
private Date earliestBizDate(List<PrcOrderBill> sources) {
Date earliest = null;
for (PrcOrderBill source : sources) {
@@ -23,7 +23,7 @@ public class PrcIncomeBillFee implements Serializable
private String currency;
private String roundMode;
private String remark;
/** 是否生成账单状态:UNBILLED/BILLED */
/** 是否生成账单状态:UNBILLED未生成/BILLED已生成 */
private String arBillStatus;
/** 是否手动增加:0否 1是 */
private String manualFlag;
@@ -97,7 +97,7 @@ public class PrcOrderBill extends BaseEntity
/** payable total */
private BigDecimal payTotal;
private String currency;
/** 应收账单占用状态:UNBILLED/BILLED。 */
/** 应收账单状态:UNBILLED未出账/PARTIAL部分出账/BILLED已出账。 */
private String arBillStatus;
/** ACTIVE / MISSING / DISABLED */
private String customerActiveStatus;
@@ -11,6 +11,10 @@ public interface PrcIncomeBillFeeMapper
public List<PrcIncomeBillFee> selectByBillIds(@Param("billIds") List<String> billIds);
/** 查询指定计费订单中尚未生成应收账单的费用。 */
public List<PrcIncomeBillFee> selectUnbilledByBillIds(@Param("billIds") List<String> billIds,
@Param("arBillStatus") String arBillStatus);
public PrcIncomeBillFee selectByFeeId(String feeId);
/** Count fees of a bill under given ar_bill_status and fee_type (e.g. BILLED + BASIC_FREIGHT). */
@@ -26,6 +30,16 @@ public interface PrcIncomeBillFeeMapper
public int updateArBillStatusByBillIds(@Param("billIds") List<String> billIds,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
/** 按费用明细 ID 精确更新状态,避免误更新同一订单的历史费用。 */
public int updateArBillStatusByFeeIds(@Param("feeIds") List<String> feeIds,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
/** 作废或失败清理后,仅在不存在其他有效账单关联时恢复为未出账。 */
public int restoreUnbilledIfNoActiveArBill(@Param("feeIds") List<String> feeIds);
/** 按应收账单批量恢复其关联费用,避免大账单组装超大的费用 ID 列表。 */
public int restoreUnbilledIfNoActiveArBillByArBillId(@Param("arBillId") Long arBillId);
public int deleteByFeeId(String feeId);
public int deleteByBillId(String billId);
@@ -29,32 +29,35 @@ public interface PrcOrderBillMapper
/** 转单号下拉搜索:按关键词模糊匹配,返回去重后的转单号(按最近创建排序,限量返回) */
public List<String> selectTransferNoOptions(@Param("keyword") String keyword, @Param("limit") int limit);
/** 应收账单生成读取指定客户截止日期前的未出账订单计费快照。 */
/** 应收账单生成读取指定客户截止日期前仍存在未出账费用的订单计费快照。 */
public List<PrcOrderBill> selectPrcOrderBillByCustomerPeriod(@Param("customerId") Long customerId,
@Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo,
@Param("excludeOrderOrTrackingNo") String excludeOrderOrTrackingNo,
@Param("cutoffDate") Date cutoffDate, @Param("currency") String currency,
@Param("arBillStatus") String arBillStatus, @Param("excludedBillIds") List<String> excludedBillIds);
@Param("arBillStatuses") List<String> arBillStatuses, @Param("feeArBillStatus") String feeArBillStatus,
@Param("excludedBillIds") List<String> excludedBillIds);
/** 统计应收账单预览结果金额,供全选模式展示。 */
BigDecimal sumPrcOrderBillIncomeByCustomerPeriod(@Param("customerId") Long customerId,
@Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo,
@Param("excludeOrderOrTrackingNo") String excludeOrderOrTrackingNo,
@Param("cutoffDate") Date cutoffDate, @Param("currency") String currency,
@Param("arBillStatus") String arBillStatus);
@Param("arBillStatuses") List<String> arBillStatuses, @Param("feeArBillStatus") String feeArBillStatus);
/** 异步生成账单按 bill_id 游标读取一批来源订单。 */
List<PrcOrderBill> selectPrcOrderBillByCustomerPeriodCursor(@Param("customerId") Long customerId,
@Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo,
@Param("cutoffDate") Date cutoffDate, @Param("currency") String currency,
@Param("arBillStatus") String arBillStatus, @Param("snapshotCreateTime") Date snapshotCreateTime,
@Param("arBillStatuses") List<String> arBillStatuses, @Param("feeArBillStatus") String feeArBillStatus,
@Param("snapshotCreateTime") Date snapshotCreateTime,
@Param("lastSourceBillId") String lastSourceBillId, @Param("billIds") List<String> billIds,
@Param("excludedBillIds") List<String> excludedBillIds, @Param("limit") int limit);
int countPrcOrderBillByCustomerPeriod(@Param("customerId") Long customerId,
@Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo,
@Param("cutoffDate") Date cutoffDate, @Param("currency") String currency,
@Param("arBillStatus") String arBillStatus, @Param("snapshotCreateTime") Date snapshotCreateTime,
@Param("arBillStatuses") List<String> arBillStatuses, @Param("feeArBillStatus") String feeArBillStatus,
@Param("snapshotCreateTime") Date snapshotCreateTime,
@Param("billIds") List<String> billIds, @Param("excludedBillIds") List<String> excludedBillIds);
/** 按生成账单时用户勾选的订单读取,并再次校验账单筛选条件。 */
@@ -62,11 +65,11 @@ public interface PrcOrderBillMapper
@Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo,
@Param("cutoffDate") Date cutoffDate,
@Param("currency") String currency, @Param("billIds") List<String> billIds,
@Param("arBillStatus") String arBillStatus);
@Param("arBillStatuses") List<String> arBillStatuses, @Param("feeArBillStatus") String feeArBillStatus);
/** 原子占用订单,防止同一订单被并发生成到多张应收账单。 */
public int claimArBillOrders(@Param("billIds") List<String> billIds, @Param("operator") String operator,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Param("arBillStatuses") List<String> arBillStatuses, @Param("toStatus") String toStatus);
/** 作废账单后释放订单,允许后续重新生成新版本账单。 */
public int releaseArBillOrders(@Param("billIds") List<String> billIds, @Param("operator") String operator,
@@ -75,6 +78,12 @@ public interface PrcOrderBillMapper
int releaseArBillOrdersByArBillId(@Param("arBillId") Long arBillId, @Param("operator") String operator,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
/** 根据费用明细状态汇总订单的 UNBILLED/PARTIAL/BILLED 状态。 */
int refreshArBillStatusByBillIds(@Param("billIds") List<String> billIds);
/** 按应收账单关联的来源订单批量汇总状态,适用于大账单清理和作废。 */
int refreshArBillStatusByArBillId(@Param("arBillId") Long arBillId);
public int insertPrcOrderBill(PrcOrderBill bill);
public int updatePrcOrderBill(PrcOrderBill bill);
@@ -16,6 +16,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.ar.enums.ArIncomeBillFeeStatus;
import com.ruoyi.ar.enums.ArOrderBillStatus;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.MessageUtils;
@@ -253,7 +254,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
fee.setCurrency(currency);
fee.setRoundMode(StringUtils.isEmpty(surcharge.getRoundMode()) ? bill.getRoundMode() : surcharge.getRoundMode());
fee.setRemark(request.getRemark());
fee.setArBillStatus(ArOrderBillStatus.UNBILLED.getCode());
fee.setArBillStatus(ArIncomeBillFeeStatus.UNBILLED.getCode());
fee.setManualFlag("1");
incomeBillFeeMapper.insertPrcIncomeBillFee(fee);
@@ -349,7 +350,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
{
throw new ServiceException(MessageUtils.message("price.bill.fee.notManual"));
}
if (ArOrderBillStatus.BILLED.getCode().equals(fee.getArBillStatus()))
if (ArIncomeBillFeeStatus.BILLED.getCode().equals(fee.getArBillStatus()))
{
throw new ServiceException(MessageUtils.message("price.bill.fee.alreadyBilled"));
}
@@ -400,6 +401,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
? BillCustomerActiveStatus.ACTIVE.getCode() : bill.getCustomerActiveStatus());
bill.setCreditArrearsStatus(creditArrearsStatus);
orderBillMapper.updatePrcOrderBill(bill);
orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId()));
return toResult(bill, fees, payBillFeeMapper.selectByBillId(bill.getBillId()));
}
@@ -639,6 +641,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
{
incomeBillFeeMapper.insertBatch(fees);
}
orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId()));
PrcPayBill payBill = persistPayBill(bill);
List<PrcPayBillFee> payFees = toPayFeeRows(bill.getBillId(),
payBill == null ? null : payBill.getPayBillId(), result);
@@ -810,7 +813,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
return;
}
int billedFreightCount = incomeBillFeeMapper.countByBillIdAndArBillStatusAndFeeType(
existed.getBillId(), ArOrderBillStatus.BILLED.getCode(), "BASIC_FREIGHT");
existed.getBillId(), ArIncomeBillFeeStatus.BILLED.getCode(), "BASIC_FREIGHT");
if (billedFreightCount > 0)
{
throw new ServiceException(MessageUtils.message("price.bill.alreadyInArBill", existed.getOrderNo()));
@@ -842,7 +845,7 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
fee.setCurrency(line.getCurrency());
fee.setRoundMode(line.getRoundMode());
fee.setRemark(line.getRemark());
fee.setArBillStatus(ArOrderBillStatus.UNBILLED.getCode());
fee.setArBillStatus(ArIncomeBillFeeStatus.UNBILLED.getCode());
fee.setManualFlag("0");
fees.add(fee);
}