:feat: 金蝶票池同步与展示查询按客商单位过滤

定时同步拉取后按客商发票信息税号集过滤,销/购方税号未同时登记的发票不入镜像(短页判定按原始条数,过滤不影响切片续扫);票池统一分页查询增加双 EXISTS 过滤与同步同开关同口径,收票登记选票与结算单认领两处展示自动生效
This commit is contained in:
刘泉佳
2026-10-16 00:33:28 +08:00
parent a76bd01482
commit e8a933cf9e
3 changed files with 74 additions and 19 deletions
@@ -44,11 +44,13 @@ public interface KingdeeInvoicePoolMapper extends BaseMapper<KingdeeInvoicePool>
/**
* 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出
*
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @param customerUnitsOnly 客商单位过滤开关:开启时只出销/购方税号均登记在客商发票信息中的发票(与定时同步同开关)
* @return 统一视图分页
*/
IPage<KingdeeInvoicePoolVO> selectInvoicePoolPage(IPage<KingdeeInvoicePoolVO> page,
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId);
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId,
@Param("customerUnitsOnly") boolean customerUnitsOnly);
}
@@ -39,12 +39,24 @@
AND p.available_amount &gt; 0 AND p.amount_use_status = '0'
</sql>
<!-- 客商单位过滤:开票单位(销方)与受票单位(购方)税号均须登记在有效客商发票信息中
(与定时同步拉取过滤同开关同口径;自定义 SQL 无租户插件,手工带租户与逻辑删除) -->
<sql id="customerUnitsFilter">
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info ci
WHERE ci.is_deleted = 0 AND ci.status = 1 AND ci.tenant_id = p.tenant_id AND ci.tax_no = p.saler_tax_no)
AND EXISTS (SELECT 1 FROM blade_customer_invoice_info cj
WHERE cj.is_deleted = 0 AND cj.status = 1 AND cj.tenant_id = p.tenant_id AND cj.tax_no = p.buyer_tax_no)
</sql>
<select id="selectInvoicePoolPage" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
SELECT
<include refid="invoicePoolVoColumns"/>
FROM blade_kingdee_invoice_pool p
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId}
<include refid="usableFilter"/>
<if test="customerUnitsOnly">
<include refid="customerUnitsFilter"/>
</if>
<if test="query.invoiceNo != null and query.invoiceNo != ''">
AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%')
</if>
@@ -63,6 +63,7 @@ import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceReceiptMapper;
import org.springblade.transport.mapper.InvoiceReceiptRecordMapper;
import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper;
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
import org.springblade.transport.mapper.KingdeeInvoicePoolMapper;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
@@ -70,6 +71,7 @@ import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.CustomerContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
@@ -146,6 +148,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private final FormalSettlementDetailMapper formalSettlementDetailMapper;
private final CustomerArchiveMapper customerArchiveMapper;
private final CustomerContactMapper customerContactMapper;
private final CustomerInvoiceInfoMapper customerInvoiceInfoMapper;
private final IBfiService bfiService;
private final BfiProperties bfiProperties;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
@@ -243,9 +246,10 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return page;
}
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出) */
/** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出;客商单位过滤与定时同步同开关同口径) */
private IPage<KingdeeInvoicePoolVO> selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) {
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId());
return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId(),
bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly());
}
/**
@@ -1248,20 +1252,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
LocalDateTime windowStart = LocalDateTime.now().minusDays(bfiProperties.getInvoiceReceipt().getIncrementDays());
LocalDateTime windowEnd = LocalDateTime.now();
List<String> invoiceTypes = this.poolInvoiceTypeCodes();
// 客商单位过滤集:开关开启时一次性载入有效客商发票信息税号(销/购方税号都须命中才入镜像),整轮共享避免逐页查询
Set<String> customerTaxNos = bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly() ? this.customerInvoiceTaxNos() : null;
SyncStats stats = new SyncStats();
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},客商单位过滤:{},单页 {} 条最多 {} 页(触顶自动二分切片)",
windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER),
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES);
invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭",
POOL_PAGE_SIZE, MAX_POOL_PAGES);
if (invoiceTypes.isEmpty()) {
this.syncWindowSlice(windowStart, windowEnd, null, stats);
this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats);
} else {
// 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度
for (String invoiceType : invoiceTypes) {
this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats);
this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats);
}
}
String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取"
+ stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化"
+ stats.pulledCount + "张发票"
+ (stats.customerFiltered > 0 ? "(客商单位过滤" + stats.customerFiltered + "张)" : "")
+ ",镜像新增" + stats.created + "更新" + stats.updated + "无变化"
+ stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged
+ ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms";
log.info("金蝶全票池同步结束,{}", summary);
@@ -1284,6 +1293,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
private static final class SyncStats {
/** A801-1 原始拉取张数 */
int pulledCount;
/** 客商单位过滤剔除张数(销/购方税号未同时登记在客商发票信息中,开关开启时统计) */
int customerFiltered;
/** 镜像新增条数(票池有、本地无) */
int created;
/** 镜像有变化并重写条数 */
@@ -1307,11 +1318,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
* 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛,
* 单片 500 条以内 1 页即短页终止)
*
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param stats 整轮统计累加器
* @param sliceStart 切片起点(含)
* @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取)
* @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享)
* @param stats 整轮统计累加器
*/
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) {
private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType,
Set<String> customerTaxNos, SyncStats stats) {
// 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏
if (!sliceStart.isBefore(sliceEnd)) {
log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd);
@@ -1325,6 +1338,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
}
stats.pageCount++;
stats.pulledCount += rows.size();
// 客商单位过滤:销/购方税号未同时登记在客商发票信息中的发票不入镜像;
// 短页判定用原始条数,过滤后的空页不能提前终止翻页
int rawPageSize = rows.size();
if (customerTaxNos != null) {
rows = rows.stream().filter(row -> this.bothUnitsRegistered(row, customerTaxNos)).toList();
stats.customerFiltered += rawPageSize - rows.size();
}
BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows);
stats.created += upsertResult.created();
stats.updated += upsertResult.updated();
@@ -1336,8 +1356,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
stats.unchanged += refreshResult.unchanged();
stats.failed += refreshResult.failed();
}
// 远程短页:该片到底,正常终止
if (rows.size() < POOL_PAGE_SIZE) {
// 远程短页:该片到底,正常终止(按原始拉取条数判断,客商过滤不改变翻页节奏)
if (rawPageSize < POOL_PAGE_SIZE) {
stats.sliceCount++;
return;
}
@@ -1355,13 +1375,34 @@ 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, invoiceType, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, stats);
this.syncWindowSlice(sliceStart, mid, invoiceType, customerTaxNos, stats);
this.syncWindowSlice(mid, sliceEnd, invoiceType, customerTaxNos, stats);
return;
}
stats.sliceCount++;
}
/**
* 有效客商发票信息税号集(状态正常且未删除,MP 租户插件自动隔离租户):
* 客商单位过滤开关开启时,同步拉取与票池展示查询共用的单位白名单
*/
private Set<String> customerInvoiceTaxNos() {
return customerInvoiceInfoMapper.selectList(Wrappers.<CustomerInvoiceInfo>lambdaQuery()
.eq(CustomerInvoiceInfo::getStatus, 1)
.eq(CustomerInvoiceInfo::getIsDeleted, 0)).stream()
.map(CustomerInvoiceInfo::getTaxNo)
.filter(StringUtil::isNotBlank)
.collect(Collectors.toSet());
}
/**
* 开票单位(销方)与受票单位(购方)税号是否均已登记在客商发票信息中;税号缺失视为未登记(开关开启时不入镜像)
*/
private boolean bothUnitsRegistered(BfiInvoicePoolVO row, Set<String> customerTaxNos) {
return StringUtil.isNotBlank(row.getSalerTaxNo()) && StringUtil.isNotBlank(row.getBuyerTaxNo())
&& customerTaxNos.contains(row.getSalerTaxNo()) && customerTaxNos.contains(row.getBuyerTaxNo());
}
/** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */
private record RefreshResult(int bound, int unchanged, int failed) {
}