feat: 全票池同步发票种类白名单下推远端——invoiceTypes单值参数按编码拆轮独立切片续扫,不再拉回后内存过滤消耗翻页额度;配置键 thirdParty.bfi.invoiceReceipt.invoiceTypes

This commit is contained in:
刘泉佳
2026-10-08 10:59:09 +08:00
parent cefe001c19
commit 7505d9bc55
2 changed files with 46 additions and 13 deletions
@@ -1179,12 +1179,18 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
"金蝶全票池同步", this::executePoolSync);
}
/** 发票种类白名单(thirdParty.bfi.invoiceReceipt.invoiceTypes 逗号分隔票易通编码,如 26,27),为空不限制 */
@Value("${thirdParty.bfi.invoiceReceipt.invoiceTypes:}")
private String poolInvoiceTypes;
/**
* 同步主体(39ffac1 绑定回填口径瘦身版):滚动增量窗口按 updateTime **切片续扫** + 镜像批量 upsert(有变化才写)
* + 未绑定存量登记顺带补绑(同页发票号码批量定位登记,页级事务批量落库)。
* <p>切片续扫(ponytail: 单查询窗口有 MAX_POOL_PAGES×500=2.5 万硬上限,窗口数据超限尾部永久漏同步;
* 切片让每片各自独立受翻页保护,总覆盖不受限):把大窗口切成小片独立翻页,某片触顶说明该时间片内
* 数据 >2.5 万,二分为 [start, mid) [mid, end) 两片递归处理;updateTime 单调,切片边界不重不漏。
* <p>发票种类白名单**下推远端**(invoiceType 为单值参数,按白名单编码逐轮查询,每轮独立切片额度),
* 白名单为空退化为单轮全量——只同步数电普票/专票时不把他票种的额度消耗进来。
* <p>拉取只认正常票(invoiceStatus=0),不推单据使用状态——在用/已用票随 updateTime 进窗口刷新
* 金额使用状态,选票可用过滤在本地 SQL 执行;被删除/红冲票不再出现在拉取结果,由保存/认领重查闸门兜底。
*
@@ -1194,11 +1200,19 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
long startMillis = System.currentTimeMillis();
LocalDateTime windowStart = LocalDateTime.now().minusDays(poolIncrementDays);
LocalDateTime windowEnd = LocalDateTime.now();
List<String> invoiceTypes = this.poolInvoiceTypeCodes();
SyncStats stats = new SyncStats();
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},单页 {} 条最多 {} 页(触顶自动二分切片)",
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER),
POOL_PAGE_SIZE, MAX_POOL_PAGES);
this.syncWindowSlice(windowStart, windowEnd, stats);
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES);
if (invoiceTypes.isEmpty()) {
this.syncWindowSlice(windowStart, windowEnd, null, stats);
} else {
// 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度
for (String invoiceType : invoiceTypes) {
this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats);
}
}
String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取"
+ stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化"
+ stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged
@@ -1207,6 +1221,18 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return summary;
}
/** 白名单编码集:逗号分隔配置解析 trim,空配置返回空集(不限制) */
private List<String> poolInvoiceTypeCodes() {
if (StringUtil.isBlank(poolInvoiceTypes)) {
return List.of();
}
return java.util.Arrays.stream(poolInvoiceTypes.split(","))
.map(String::trim)
.filter(StringUtil::isNotBlank)
.distinct()
.toList();
}
/** 整轮同步累计统计(各切片共享累加,结束进摘要) */
private static final class SyncStats {
/** A801-1 原始拉取张数 */
@@ -1238,7 +1264,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param stats 整轮统计累加器
*/
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, SyncStats stats) {
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) {
// 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏
if (!sliceStart.isBefore(sliceEnd)) {
log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd);
@@ -1246,7 +1272,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
boolean truncated = false;
for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) {
List<BfiInvoicePoolVO> rows = this.pullPoolWindowPage(sliceStart, sliceEnd, pageNo);
List<BfiInvoicePoolVO> rows = this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo);
if (rows.isEmpty()) {
break;
}
@@ -1282,8 +1308,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})",
sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd);
this.syncWindowSlice(sliceStart, mid, stats);
this.syncWindowSlice(mid, sliceEnd, stats);
this.syncWindowSlice(sliceStart, mid, invoiceType, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, stats);
return;
}
stats.sliceCount++;
@@ -1364,14 +1390,16 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return syncRecord;
}
/** A801-1 按票池更新时间窗口分页拉取(只认正常票,不推单据使用状态,票面可用性由本地过滤) */
private List<BfiInvoicePoolVO> pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd, int pageNo) {
/** A801-1 按票池更新时间窗口分页拉取(只认正常票,不推单据使用状态;invoiceType 单值下推,null 不限) */
private List<BfiInvoicePoolVO> pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd,
String invoiceType, int pageNo) {
BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO();
data.setPageNo(pageNo);
data.setPageSize(POOL_PAGE_SIZE);
data.setUpdateTime(windowStart.format(POOL_TIME_FORMATTER));
data.setUpdateTimeEnd(windowEnd.format(POOL_TIME_FORMATTER));
data.setInvoiceStatus(KingdeeBfiConstant.POOL_INVOICE_STATUS_NORMAL);
data.setInvoiceType(invoiceType);
BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO();
param.setQueryParam(data);
BfiPageVO<BfiInvoicePoolVO> poolPage = bfiService.queryInvoicePool(param);
@@ -47,10 +47,15 @@
```
InvoiceReceiptKingdeeSyncTask(@Scheduled cron,thirdParty.bfi.invoiceReceipt.cron 默认每30分钟,syncEnabled 默认关闭)
→ IInvoiceReceiptService.syncKingdeeInvoicePool()(KingdeeSyncLockSupport.runSingleFlight 单飞,锁键按租户)
→ executePoolSync():滚动近 incrementDays 天(默认30)窗口按 updateTime 分页拉取(pageSize 500,最多 50 页保护)
→ executePoolSync():滚动近 incrementDays 天(默认30)窗口
① 发票种类白名单下推(thirdParty.bfi.invoiceReceipt.invoiceTypes,单值参数按编码拆轮,每轮独立额度;空不限制)
② updateTime 切片续扫(syncWindowSlice):单查询窗口受 50页×500条=2.5万硬上限,触顶自动二分
[start,mid)+[mid,end) 递归续扫(右片不含 mid 不重不漏;细到 1 秒仍满页防御报 error 跳过)
出站口径 invoiceStatus="0",不推 expenseStatus(在用/已用票随 updateTime 进窗口刷新使用状态,本地过滤自然排除)
→ batchUpsertInvoicePool:同页号码一次批量查既有镜像 → fillInvoicePool 全列填充 → Db.saveBatch / Db.updateBatchById
(全列覆盖、无指纹比对;源侧空字段维持库中原值——MP 非空字段更新策略,与手动/回源路径同口径)
→ batchUpsertInvoicePool:同页号码一次批量查既有镜像 → fillInvoicePool 全列填充 → 有变化才写:
轻量比对 serialNo/价税合计/税额/剩余可用金额/金额使用状态(compareTo 比金额,空对空不算变化),
无变化跳过 UPDATE(消整窗重复拉取的无效写);有变化 Db.updateBatchById、新增 Db.saveBatch
→ 已登记票跟随补绑:同页票号批量定位登记,仅未绑定存量登记补绑+写 sync 流水(页级事务批量落库)
→ 失败中断本轮由任务层记日志,下轮幂等重试
```
@@ -63,7 +68,7 @@ InvoiceReceiptKingdeeSyncTask(@Scheduled cron,thirdParty.bfi.invoiceReceipt.
| --- | --- |
| 镜像表新增列 | `available_amount` decimal(18,2)、`amount_use_status` varchar(8),幂等脚本 `doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql`(可重跑) |
| 新增索引 | `blade_formal_settlement_invoice (tenant_id, invoice_no)`(认领过滤 EXISTS 走索引) |
| Nacos | `third-party-api.yaml` 新增 `thirdParty.bfi.invoiceReceipt.*`:`syncEnabled`(false)/`cron`(每30分钟)/`incrementDays`(30)/`lockLeaseSeconds`(1800) |
| Nacos | `third-party-api.yaml` 新增 `thirdParty.bfi.invoiceReceipt.*`:`syncEnabled`(false)/`cron`(每30分钟)/`incrementDays`(30)/`invoiceTypes`(白名单编码,如 26,27,下推远端按编码拆轮)/`lockLeaseSeconds`(1800) |
| 存量数据 | 存量存根行两列为 NULL,不参与选票(可用过滤排除),随增量窗口同步自然回填;回切当天手动跑一次 `POST /invoice-receipt/sync-invoice-pool` bootstrap 铺底 |
## 6. 前端统一组件(`src/components/kingdee-invoice-pool-dialog/main.vue`)