diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 3e418c9c..2483416f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -50,7 +50,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; import org.springblade.thirdparty.bfi.service.IBfiService; -import org.springblade.transport.constantq.KingdeeAttachmentConstant; +import org.springblade.transport.constant.KingdeeAttachmentConstant; import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.CustomerArchiveMapper; @@ -70,7 +70,6 @@ import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.CustomerContact; import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.FormalSettlementDetail; -import org.springblade.transport.pojo.entity.FormalSettlementInvoice; import org.springblade.transport.pojo.entity.InvoiceReceipt; import org.springblade.transport.pojo.entity.InvoiceReceiptRecord; import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement; @@ -1181,11 +1180,12 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl切片续扫(ponytail: 单查询窗口有 MAX_POOL_PAGES×500=2.5 万硬上限,窗口数据超限尾部永久漏同步; + * 切片让每片各自独立受翻页保护,总覆盖不受限):把大窗口切成小片独立翻页,某片触顶说明该时间片内 + * 数据 >2.5 万,二分为 [start, mid) [mid, end) 两片递归处理;updateTime 单调,切片边界不重不漏。 + *

拉取只认正常票(invoiceStatus=0),不推单据使用状态——在用/已用票随 updateTime 进窗口刷新 * 金额使用状态,选票可用过滤在本地 SQL 执行;被删除/红冲票不再出现在拉取结果,由保存/认领重查闸门兜底。 * * @return 执行摘要 @@ -1194,55 +1194,99 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛, + * 单片 500 条以内 1 页即短页终止) + * + * @param sliceStart 切片起点(含) + * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) + * @param stats 整轮统计累加器 + */ + private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, SyncStats stats) { + // 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏 + if (!sliceStart.isBefore(sliceEnd)) { + log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd); + return; + } + boolean truncated = false; for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) { - List rows = this.pullPoolWindowPage(windowStart, windowEnd, pageNo); + List rows = this.pullPoolWindowPage(sliceStart, sliceEnd, pageNo); if (rows.isEmpty()) { break; } - pageCount++; - pulledCount += rows.size(); + stats.pageCount++; + stats.pulledCount += rows.size(); BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows); - created += upsertResult.created(); - updated += upsertResult.updated(); - // 已登记票跟随刷新(39ffac1 口径):同页发票号码批量定位登记,内存比对变更后页级事务批量落库 + stats.created += upsertResult.created(); + stats.updated += upsertResult.updated(); + stats.mirrorUnchanged += upsertResult.unchanged(); + // 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库 if (!upsertResult.mirrors().isEmpty()) { - RefreshResult refreshResult = this.refreshBoundReceipts(pageNo, upsertResult.mirrors()); - bound += refreshResult.bound(); - unchanged += refreshResult.unchanged(); - failed += refreshResult.failed(); + RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors()); + stats.bound += refreshResult.bound(); + stats.unchanged += refreshResult.unchanged(); + stats.failed += refreshResult.failed(); } + // 远程短页:该片到底,正常终止 if (rows.size() < POOL_PAGE_SIZE) { - break; + stats.sliceCount++; + return; } + truncated = true; } - if (pageCount >= MAX_POOL_PAGES) { - // 末轮仍满页说明窗口数据量已超过翻页保护,尾部数据本轮未处理(须缩小 incrementDays 或调大保护页数) - log.warn("金蝶全票池同步翻页达最大保护 {} 页({} 条)仍未到底,窗口尾部本轮未处理", - MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE); + // 触顶:该切片数据超过单查询窗口上限,从中点二分续扫(updateTime 落在中点的行归左片 [start, mid), + // 右片 [mid, end) 不含 mid,同 updateTime 行整体归左片,不重不漏) + if (truncated) { + LocalDateTime mid = sliceStart.plus(java.time.Duration.between(sliceStart, sliceEnd).dividedBy(2)); + if (!mid.isAfter(sliceStart)) { + // 切片已细到不足 1 秒仍满页:理论不可能,防御死递归 + log.error("金蝶全票池同步切片 {} ~ {} 已达最小粒度仍满页(>{} 条),该时间片数据异常,跳过", + sliceStart, sliceEnd, MAX_POOL_PAGES * POOL_PAGE_SIZE); + return; + } + 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); + return; } - String summary = "金蝶全票池同步:翻页" + pageCount + "页拉取" + pulledCount + "张发票,镜像新增" + created - + "更新" + updated + ",补绑" + bound + ",登记无变化跳过" + unchanged - + ",失败" + failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms"; - log.info("金蝶全票池同步结束,{}", summary); - return summary; + stats.sliceCount++; } /** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */ @@ -1345,7 +1389,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl rows) { Map mirrorMap = new LinkedHashMap<>(); if (rows.isEmpty()) { - return new BatchUpsertResult(mirrorMap, 0, 0); + return new BatchUpsertResult(mirrorMap, 0, 0, 0); } List invoiceNos = rows.stream() .map(row -> row.getInvoiceNo().trim()) @@ -1358,16 +1402,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl first)); List toInsert = new ArrayList<>(); List toUpdate = new ArrayList<>(); + int unchangedCount = 0; for (BfiInvoicePoolVO row : rows) { if (Func.isEmpty(row.getInvoiceNo())) { continue; } - KingdeeInvoicePool mirror = fillInvoicePool(existingMap.get(row.getInvoiceNo().trim()), row); + KingdeeInvoicePool existing = existingMap.get(row.getInvoiceNo().trim()); + KingdeeInvoicePool mirror = fillInvoicePool(existing, row); mirrorMap.put(mirror.getInvoiceNo(), mirror); - if (mirror.getId() == null) { + if (existing == null) { toInsert.add(mirror); - } else { + } else if (poolMirrorChanged(existing, mirror)) { toUpdate.add(mirror); + } else { + // 票面无变化跳过重写:整窗重复拉取时消掉每轮 2.5 万行无效写 + unchangedCount++; } } // 批量写库:失败中断本轮,由任务层记录异常、下轮重试(upsert 幂等) @@ -1377,12 +1426,43 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl 镜像,新增条数、更新条数) */ - private record BatchUpsertResult(Map mirrors, int created, int updated) { + /** + * 镜像票面是否有变化(轻量比对,ponytail: 只比对"会变且影响业务"的字段, + * 不搞人工字段清单——清单漏新增列即静默不同步是旧版指纹的坑): + * 流水号(金蝶侧标识)、价税合计、税额、剩余可用金额、金额使用状态。 + * BigDecimal 比对用 compareTo(13.00 与 13.0 等价);空值视为变化跳过重写(与 MP 非空字段更新策略一致) + * + * @param existing 库中既有镜像 + * @param filled 填充后的镜像 + * @return true 表示有变化需重写 + */ + private boolean poolMirrorChanged(KingdeeInvoicePool existing, KingdeeInvoicePool filled) { + if (!Objects.equals(existing.getSerialNo(), filled.getSerialNo()) + || !Objects.equals(existing.getAmountUseStatus(), filled.getAmountUseStatus())) { + return true; + } + return amountChanged(existing.getInvoiceAmount(), filled.getInvoiceAmount()) + || amountChanged(existing.getTaxAmount(), filled.getTaxAmount()) + || amountChanged(existing.getAvailableAmount(), filled.getAvailableAmount()); + } + + /** 金额变化比对:双方均空视为无变化;一方空一方有视为有变化;否则按数值比较 */ + private boolean amountChanged(BigDecimal before, BigDecimal after) { + if (before == null && after == null) { + return false; + } + if (before == null || after == null) { + return true; + } + return before.compareTo(after) != 0; + } + + /** 票池镜像批量 upsert 结果(invoiceNo -> 镜像,新增条数、更新条数、无变化条数) */ + private record BatchUpsertResult(Map mirrors, int created, int updated, int unchanged) { } // ==================== 结算单认领确认重查闸门 ====================