feat: 金蝶票池选票仅出可用票并改造有界循环补页游标分页

- 选票过滤:剩余可用金额availableAmount>0且金额使用状态amountUseStatus=0(未使用),保存落镜像前按同口径复核,防选票到保存之间票被他人使用造成超用
- 单据使用状态不再作为前端条件,后端固定下推未用(expenseStatus=1)
- 分页改造:单请求最多3次远程调用循环补页凑满一页,返回nextCursor(远程页码:已消费过滤行数)供下一页续扫,防过滤后翻页漏行重行,游标解析失败降级页码锚定
- 新增剩余可用金额availableTaxAmount展示字段(金蝶可用税额口径,不改可认领金额的价税合计口径)
This commit is contained in:
刘泉佳
2026-09-23 15:06:07 +08:00
parent 8c52d47258
commit c91ab359ba
5 changed files with 167 additions and 56 deletions
@@ -73,12 +73,6 @@ public class KingdeeInvoicePoolQueryDTO implements Serializable {
@Schema(description = "开票日期止 yyyy-MM-dd")
private String invoiceDateEnd;
/**
* 发票类型编码(票易通字典,如 26/27)
*/
@Schema(description = "发票类型编码(票易通字典,如 26/27)")
private String invoiceType;
/**
* 销方税号,精确
*/
@@ -92,8 +86,14 @@ public class KingdeeInvoicePoolQueryDTO implements Serializable {
private String buyerTaxNo;
/**
* 单据使用状态[1未用,30在用,60已用,65已入账],空不限(A801-1 原生条件)
* 发票类型编码(票易通字典,如 26/27)
*/
@Schema(description = "单据使用状态[1未用,30在用,60已用,65已入账]")
private String expenseStatus;
@Schema(description = "发票类型编码(票易通字典,如 26/27)")
private String invoiceType;
/**
* 续扫游标:下一页翻页时原样回传上页返回的 nextCursor,空表示按页码锚定重新扫描
*/
@Schema(description = "续扫游标(下一页请求回传上页 nextCursor)")
private String cursor;
}
@@ -56,7 +56,11 @@ public class KingdeeInvoicePoolPageVO implements Serializable {
@Schema(description = "每页条数")
private Long size;
/** 是否还有下一页(服务端多取 1 条探测) */
/** 是否还有下一页(服务端有界补页扫描判断) */
@Schema(description = "是否还有下一页")
private Boolean hasMore;
/** 下一页续扫游标("远程页码:已消费过滤行数"),null 表示没有更多;翻下一页时原样回传 */
@Schema(description = "下一页续扫游标,null 表示没有更多")
private String nextCursor;
}
@@ -139,6 +139,10 @@ public class KingdeeInvoicePoolVO implements Serializable {
@Schema(description = "可认领金额")
private BigDecimal availableInvoiceAmount;
/** 剩余可用金额(金蝶可用税额口径,与可认领金额的价税合计口径不同) */
@Schema(description = "剩余可用金额")
private BigDecimal availableTaxAmount;
/** 默认认领金额,口径同发票金额 */
@Schema(description = "默认认领金额")
private BigDecimal matchedAmount;
@@ -182,6 +182,18 @@ public final class KingdeeBfiConstant {
/** A103-2 单据状态:已关闭。 */
public static final String BFI_BILL_STATUS_CLOSED = "D";
/** A801-1 查询限定发票状态:0 正常(票易通字典),作废/异常票不进镜像、不可补查 */
public static final String POOL_INVOICE_STATUS_NORMAL = "0";
/** 选票金额使用状态可选值:仅保留剩余可用金额>0 且金额使用状态为 0 的可用票,2-全部使用 */
public static final String POOL_AMOUNT_USE_STATUS_SELECTABLE = "0";
/** 单请求远程补页调用上限:金额可用过滤在拉取后进行,过滤率过低时以此封顶防请求无限拉长 */
public static final int POOL_MAX_REMOTE_CALLS = 3;
/** 选票单据使用状态固定为未用(1,票易通字典):仅未使用的票可被认领/登记,前端不提供该筛选条件 */
public static final String POOL_EXPENSE_STATUS_UNUSED = "1";
/**
* 按中文名称从映射字典转换单据类型编码。
*
@@ -110,9 +110,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
/** A801-1 rim 契约单页最大条数 */
private static final int POOL_PAGE_SIZE = 500;
/** A801-1 查询限定发票状态:0 正常(票易通字典),作废/异常票不进镜像、不可补查 */
private static final String POOL_INVOICE_STATUS_NORMAL = "0";
private final KingdeeInvoicePoolMapper invoicePoolMapper;
private final InvoiceReceiptSettlementMapper settlementRelationMapper;
private final InvoiceReceiptRecordMapper recordMapper;
@@ -177,44 +174,127 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
long size = query.getSize() == null ? 10L : Math.min(Math.max(query.getSize(), 1L), 100L);
// 查询票号(已 trim 归一,方法级可见):用于对远程模糊命中做全等防御
String queryInvoiceNo = trimOrNull(query.getInvoiceNo());
BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO();
data.setPageNo((int) current);
// A801-1 无分页总数:多取 1 条探测下一页,返回前截断
data.setPageSize((int) size + 1);
// 固定只认正常状态票,作废/异常票不进选票结果
data.setInvoiceStatus(POOL_INVOICE_STATUS_NORMAL);
data.setInvoiceNo(queryInvoiceNo);
data.setSalerTaxNo(trimOrNull(query.getSalerTaxNo()));
data.setBuyerTaxNo(trimOrNull(query.getBuyerTaxNo()));
data.setInvoiceType(trimOrNull(query.getInvoiceType()));
data.setExpenseStatus(trimOrNull(query.getExpenseStatus()));
data.setInvoiceDateStart(normalizeQueryDate(query.getInvoiceDateStart()));
data.setInvoiceDateEnd(normalizeQueryDate(query.getInvoiceDateEnd()));
BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO();
param.setQueryParam(data);
BfiPageVO<BfiInvoicePoolVO> poolPage = bfiService.queryInvoicePool(param);
// WHY: hasMore 以远程原始行数为准,防被过滤行(空票号/模糊命中)造成末页假阴性
int rawCount = poolPage.getRows() == null ? 0 : poolPage.getRows().size();
boolean hasMore = rawCount > size;
List<BfiInvoicePoolVO> rows = Optional.ofNullable(poolPage.getRows()).orElse(List.of()).stream()
.filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()))
// 按票号查询时全等防御:远程为模糊匹配,命中"包含"关系的其他票号不得混入选票结果
.filter(row -> queryInvoiceNo == null || queryInvoiceNo.equals(row.getInvoiceNo().trim()))
.toList();
// 过滤后行数可能小于 size(空票号/模糊命中剔除),截断下标须夹取到过滤后列表长度,防 toIndex 越界
int end = (int) Math.min(size, rows.size());
List<BfiInvoicePoolVO> pageRows = hasMore ? rows.subList(0, end) : rows;
// WHY: 金额可用过滤发生在拉取之后,下一页必须按游标续扫才能跨请求对齐过滤进度,按页码锚定会漏行/重行
PoolScanCursor cursor = PoolScanCursor.parse(query.getCursor());
List<BfiInvoicePoolVO> window = new ArrayList<>();
// scanPageNo 指向待拉取的远程页;游标续扫固定按 500 行窗口取数,保证"页码:过滤偏移"语义稳定
long scanPageNo = cursor == null ? current : cursor.pageNo();
int skip = cursor == null ? 0 : cursor.skip();
boolean exhausted = false;
// 末次拉取前的过滤累计行数与末次拉取页码:未消费的过滤行只可能落在末次拉取页内
int filteredBeforeLastFetch = 0;
long lastFetchPageNo = scanPageNo;
int remoteCalls = 0;
// 有界循环补页:单请求最多 POOL_MAX_REMOTE_CALLS 次远程调用凑满一页,仍不足则返回稀疏页并给出游标
for (int call = 0; call < KingdeeBfiConstant.POOL_MAX_REMOTE_CALLS && window.size() - skip < size; call++) {
// 首拉按 size+1 轻量探测,补拉按契约上限 500 行以减少调用次数
int fetchSize = call == 0 && cursor == null ? (int) size + 1 : POOL_PAGE_SIZE;
List<BfiInvoicePoolVO> rawRows = this.queryPoolPage(scanPageNo, fetchSize, query);
remoteCalls++;
List<BfiInvoicePoolVO> usableRows = rawRows.stream()
.filter(this::isUsablePoolRow)
// 按票号查询时全等防御:远程为模糊匹配,命中"包含"关系的其他票号不得混入选票结果
.filter(row -> queryInvoiceNo == null || queryInvoiceNo.equals(row.getInvoiceNo().trim()))
.toList();
filteredBeforeLastFetch = window.size();
window.addAll(usableRows);
lastFetchPageNo = scanPageNo;
// WHY: A801-1 无总数,远程短页是唯一耗尽信号
if (rawRows.size() < fetchSize) {
exhausted = true;
break;
}
scanPageNo++;
}
// 按游标偏移切页:skip 为游标窗口内已消费的过滤行数
int fromIndex = Math.min(skip, window.size());
int end = Math.min(fromIndex + (int) size, window.size());
List<BfiInvoicePoolVO> pageRows = window.subList(fromIndex, end);
// 末次拉取页内已消费的过滤行数:翻页游标锚定在该页,页内剩余行下页续用
int consumedInLastPage = Math.max(0, end - Math.max(fromIndex, filteredBeforeLastFetch));
boolean hasMore = !exhausted || end < window.size();
List<KingdeeInvoicePoolVO> records = pageRows.stream().map(this::toPoolViewVO).toList();
decorateUseFlags(records);
this.decorateUseFlags(records);
KingdeeInvoicePoolPageVO result = new KingdeeInvoicePoolPageVO();
result.setCurrent(current);
result.setSize(size);
result.setHasMore(hasMore);
result.setRecords(records);
log.info("金蝶票池实时查询 第{}页 {}条/页 返回{}条 hasMore:{}", current, size, records.size(), hasMore);
result.setNextCursor(hasMore ? PoolScanCursor.encode(lastFetchPageNo, consumedInLastPage) : null);
log.info("金蝶票池实时查询 第{}页 {}条/页 返回{}条 hasMore:{} 远程调用{}次 游标:{}", current, size,
records.size(), hasMore, remoteCalls, result.getNextCursor());
return result;
}
/** A801-1 按页拉取原始行:查询条件清洗与选票口径集中在此,循环补页多次复用 */
private List<BfiInvoicePoolVO> queryPoolPage(long pageNo, int pageSize, KingdeeInvoicePoolQueryDTO query) {
BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO();
data.setPageNo((int) pageNo);
data.setPageSize(pageSize);
// 固定只认正常状态票,作废/异常票不进选票结果
data.setInvoiceStatus(KingdeeBfiConstant.POOL_INVOICE_STATUS_NORMAL);
data.setInvoiceNo(trimOrNull(query.getInvoiceNo()));
data.setSalerTaxNo(trimOrNull(query.getSalerTaxNo()));
data.setBuyerTaxNo(trimOrNull(query.getBuyerTaxNo()));
data.setInvoiceType(trimOrNull(query.getInvoiceType()));
// 单据使用状态固定查未用,不走前端条件
data.setExpenseStatus(KingdeeBfiConstant.POOL_EXPENSE_STATUS_UNUSED);
data.setInvoiceDateStart(normalizeQueryDate(query.getInvoiceDateStart()));
data.setInvoiceDateEnd(normalizeQueryDate(query.getInvoiceDateEnd()));
BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO();
param.setQueryParam(data);
BfiPageVO<BfiInvoicePoolVO> poolPage = bfiService.queryInvoicePool(param);
return Optional.ofNullable(poolPage.getRows()).orElse(List.of());
}
/**
* 选票可用过滤(选票弹窗与保存落镜像同口径):票号非空 + 剩余可用金额>0 + 金额使用状态为 0(未使用);
* 可用金额空白/解析失败由 parseAmount 按 0 兜底,即无法证明可用视为不可用
*/
private boolean isUsablePoolRow(BfiInvoicePoolVO row) {
if (row == null || StringUtil.isBlank(row.getInvoiceNo())) {
return false;
}
String amountUseStatus = row.getAmountUseStatus();
if (!KingdeeBfiConstant.POOL_AMOUNT_USE_STATUS_SELECTABLE.equals(amountUseStatus == null ? null : amountUseStatus.trim())) {
return false;
}
return parseAmount(row.getInvoiceNo(), row.getAvailableAmount()).compareTo(BigDecimal.ZERO) > 0;
}
/**
* 票池扫描游标("远程页码:已消费过滤行数"):过滤发生在拉取之后,翻页按游标续扫才能跨请求对齐过滤进度;
* 游标页码统一按 500 行窗口定位,解析失败降级为按页码锚定(仅存在翻页错位风险,不阻断查询)
*/
private record PoolScanCursor(long pageNo, int skip) {
static PoolScanCursor parse(String text) {
if (StringUtil.isBlank(text)) {
return null;
}
String[] parts = text.trim().split(":");
if (parts.length != 2) {
log.warn("金蝶票池续扫游标格式非法,忽略后按页码锚定,游标={}", text);
return null;
}
try {
long pageNo = Long.parseLong(parts[0].trim());
int skip = Integer.parseInt(parts[1].trim());
if (pageNo < 1 || skip < 0) {
throw new NumberFormatException("游标数值越界");
}
return new PoolScanCursor(pageNo, skip);
} catch (NumberFormatException exception) {
log.warn("金蝶票池续扫游标解析失败,忽略后按页码锚定,游标={}", text);
return null;
}
}
static String encode(long pageNo, int skip) {
return pageNo + ":" + skip;
}
}
/** 空白串归一为 null,便于远程查询条件与 MyBatis-Plus 判空 */
private String trimOrNull(String text) {
return StringUtil.isBlank(text) ? null : text.trim();
@@ -248,6 +328,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
vo.setInvoiceAmount(totalAmount);
vo.setAvailableInvoiceAmount(totalAmount);
vo.setMatchedAmount(totalAmount);
// 剩余可用金额为金蝶可用税额口径,仅用于选票展示,与可认领金额的价税合计口径不同
vo.setAvailableTaxAmount(parseAmount(row.getInvoiceNo(), row.getAvailableAmount()));
vo.setReceiverName(row.getBuyerName());
vo.setBuyerTaxNo(row.getBuyerTaxNo());
vo.setIssuerName(row.getSalerName());
@@ -629,14 +711,18 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存续推指定", receipt.getInvoiceNo(), billNo);
} else {
try {
// 镜像缺明细行/票种编码(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写,镜像入池时已过白名单,回填不再复检
if (Func.isBlank(plan.mirror().getItemsJson()) || Func.isBlank(plan.mirror().getInvoiceTypeCode())) {
log.info("收票登记[{}]镜像缺明细/票种编码,按票号回填 A801-1", receipt.getInvoiceNo());
// 镜像缺票种编码(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写;
// 明细分录已与应付单同源取自结算货物费用行,票池明细行仅入池存档、不再作为分录取数来源
if (Func.isBlank(plan.mirror().getInvoiceTypeCode())) {
log.info("收票登记[{}]镜像缺票种编码,按票号回填 A801-1", receipt.getInvoiceNo());
fetchMirrorItemsFromPool(plan.mirror());
}
log.info("收票登记[{}]开始 A303-2 推送金蝶收票单", receipt.getInvoiceNo());
ContractManage contract = kingdeeApBillSyncSupport.requireContract(plan.settlement().getContractId(), context);
billNo = kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, context);
// 明细分录与应付单 A102-2 同源同口径:复用同一结算明细解析结果,逐结算货物费用行一条
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups =
kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context);
billNo = kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context);
log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}]", receipt.getInvoiceNo(), billNo);
} catch (RuntimeException pushException) {
log.warn("收票登记[{}]A303-2 推送失败:{}", receipt.getInvoiceNo(), pushException.getMessage());
@@ -672,21 +758,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
Map<Long, List<FormalSettlementDetail>> detailsBySettlement) {
String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED);
if (!TransportBusiConstant.APPROVED.equals(receipt.getApprovalStatus())) {
return new ReceivedInvoicePushPlan(receipt, null, null, null, "仅审批通过的收票登记允许推送金蝶");
return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "仅审批通过的收票登记允许推送金蝶");
}
if (KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) {
return new ReceivedInvoicePushPlan(receipt, null, null, null, "收票单已指定应付,无需重复推送");
return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票单已指定应付,无需重复推送");
}
KingdeeInvoicePool mirror = receipt.getKingdeeInvoicePoolId() == null ? null
: mirrorById.get(receipt.getKingdeeInvoicePoolId());
if (mirror == null) {
return new ReceivedInvoicePushPlan(receipt, null, null, null, "收票登记未绑定票池镜像,请先完成同步绑定");
return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票登记未绑定票池镜像,请先完成同步绑定");
}
if ("2".equals(mirror.getCheckStatus())) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, "金蝶发票查验不通过,请先人工核实票池发票");
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "金蝶发票查验不通过,请先人工核实票池发票");
}
if (relations.size() != 1 || relations.get(0).getFormalSettlementId() == null) {
return new ReceivedInvoicePushPlan(receipt, mirror, null, null,
return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null,
"金蝶不支持一张发票指定多张应付单,收票登记须恰好分摊一张应付正式结算单");
}
FormalSettlement settlement = settlementById.get(relations.get(0).getFormalSettlementId());
@@ -694,14 +780,14 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
|| !Objects.equals(settlement.getStatus(), 1)
|| !TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())
|| !"payable".equals(settlement.getSettlementType())) {
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, "分摊结算单不是审批通过的应付正式结算单");
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null, "分摊结算单不是审批通过的应付正式结算单");
}
List<FormalSettlementDetail> details = detailsBySettlement.getOrDefault(settlement.getId(), List.of());
if (details.isEmpty()) {
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null,
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null,
"结算单" + settlement.getFormalSettlementNo() + "无结算明细,无法组装应付单防重键");
}
return new ReceivedInvoicePushPlan(receipt, mirror, settlement,
return new ReceivedInvoicePushPlan(receipt, mirror, settlement, details,
kingdeeApBillSyncSupport.apBillSrcCode(settlement, details), null);
}
@@ -771,11 +857,12 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
* @param receipt 收票登记
* @param mirror 绑定的票池镜像(可为 null)
* @param settlement 唯一分摊的应付正式结算单(可为 null)
* @param details 结算单结算明细(防重键组装与 A303-2 分录解析共用,可为 null)
* @param apBillSrcCode 财务应付单防重键(无法组装时为 null)
* @param blockedReason 前置拦截原因
*/
private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror,
FormalSettlement settlement, String apBillSrcCode, String blockedReason) {
FormalSettlement settlement, List<FormalSettlementDetail> details, String apBillSrcCode, String blockedReason) {
}
// ==================== A801-3 收票结果定时回写(异常回收/指定补偿/单号补登) ====================
@@ -923,7 +1010,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
data.setPageSize(POOL_PAGE_SIZE);
data.setInvoiceNo(invoiceNo.trim());
// 只认发票状态正常的票,作废/异常票不再进入镜像
data.setInvoiceStatus(POOL_INVOICE_STATUS_NORMAL);
data.setInvoiceStatus(KingdeeBfiConstant.POOL_INVOICE_STATUS_NORMAL);
BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO();
param.setQueryParam(data);
BfiPageVO<BfiInvoicePoolVO> page = bfiService.queryInvoicePool(param);
@@ -950,6 +1037,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
if (poolRow == null) {
throw new ServiceException("金蝶票池未查询到发票 " + trimmedInvoiceNo + ",不能保存");
}
// 保存防御:选票到保存之间票可能被他人认领/使用,落镜像前按选票同口径复核可用性
if (!this.isUsablePoolRow(poolRow)) {
throw new ServiceException("发票 " + trimmedInvoiceNo + " 剩余可用金额为0或金额使用状态已变更,不能保存");
}
return upsertInvoicePool(existing, poolRow);
}