From c91ab359baba06222bdc930acd92d463e881be50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Wed, 23 Sep 2026 15:06:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=91=E8=9D=B6=E7=A5=A8=E6=B1=A0?= =?UTF-8?q?=E9=80=89=E7=A5=A8=E4=BB=85=E5=87=BA=E5=8F=AF=E7=94=A8=E7=A5=A8?= =?UTF-8?q?=E5=B9=B6=E6=94=B9=E9=80=A0=E6=9C=89=E7=95=8C=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E8=A1=A5=E9=A1=B5=E6=B8=B8=E6=A0=87=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 选票过滤:剩余可用金额availableAmount>0且金额使用状态amountUseStatus=0(未使用),保存落镜像前按同口径复核,防选票到保存之间票被他人使用造成超用 - 单据使用状态不再作为前端条件,后端固定下推未用(expenseStatus=1) - 分页改造:单请求最多3次远程调用循环补页凑满一页,返回nextCursor(远程页码:已消费过滤行数)供下一页续扫,防过滤后翻页漏行重行,游标解析失败降级页码锚定 - 新增剩余可用金额availableTaxAmount展示字段(金蝶可用税额口径,不改可认领金额的价税合计口径) --- .../pojo/dto/KingdeeInvoicePoolQueryDTO.java | 18 +- .../pojo/vo/KingdeeInvoicePoolPageVO.java | 6 +- .../pojo/vo/KingdeeInvoicePoolVO.java | 4 + .../constant/KingdeeBfiConstant.java | 12 ++ .../impl/InvoiceReceiptServiceImpl.java | 183 +++++++++++++----- 5 files changed, 167 insertions(+), 56 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java index 535de560..cf5a24e8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java @@ -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; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java index d2c1d8e5..4e80ad61 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java @@ -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; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolVO.java index e20bcb89..001bae66 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolVO.java @@ -139,6 +139,10 @@ public class KingdeeInvoicePoolVO implements Serializable { @Schema(description = "可认领金额") private BigDecimal availableInvoiceAmount; + /** 剩余可用金额(金蝶可用税额口径,与可认领金额的价税合计口径不同) */ + @Schema(description = "剩余可用金额") + private BigDecimal availableTaxAmount; + /** 默认认领金额,口径同发票金额 */ @Schema(description = "默认认领金额") private BigDecimal matchedAmount; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java index 72796707..d9d6f025 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java @@ -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"; + /** * 按中文名称从映射字典转换单据类型编码。 * 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 ad1c345a..13bcd4b3 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 @@ -110,9 +110,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl poolPage = bfiService.queryInvoicePool(param); - // WHY: hasMore 以远程原始行数为准,防被过滤行(空票号/模糊命中)造成末页假阴性 - int rawCount = poolPage.getRows() == null ? 0 : poolPage.getRows().size(); - boolean hasMore = rawCount > size; - List 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 pageRows = hasMore ? rows.subList(0, end) : rows; + // WHY: 金额可用过滤发生在拉取之后,下一页必须按游标续扫才能跨请求对齐过滤进度,按页码锚定会漏行/重行 + PoolScanCursor cursor = PoolScanCursor.parse(query.getCursor()); + List 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 rawRows = this.queryPoolPage(scanPageNo, fetchSize, query); + remoteCalls++; + List 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 pageRows = window.subList(fromIndex, end); + // 末次拉取页内已消费的过滤行数:翻页游标锚定在该页,页内剩余行下页续用 + int consumedInLastPage = Math.max(0, end - Math.max(fromIndex, filteredBeforeLastFetch)); + boolean hasMore = !exhausted || end < window.size(); List 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 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 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 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> 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 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 details, String apBillSrcCode, String blockedReason) { } // ==================== A801-3 收票结果定时回写(异常回收/指定补偿/单号补登) ==================== @@ -923,7 +1010,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl page = bfiService.queryInvoicePool(param); @@ -950,6 +1037,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl