⚡ 金蝶全票池定时同步按变更比对落库并加 single-flight 锁

- 镜像批量 upsert 增加票面字段指纹比对,票面无变化不重写(含 attachments_json 大字段),口径对齐 MyBatis-Plus 非空字段更新策略
- 收票登记绑定前比对 kingdeeInvoicePoolId/kingdeeBillNo/kingdeeStatus,三项均无变化不更新;同步流水只在绑定关系变化或金蝶状态迁移时写入,原因文案按语义拆分为「定时绑定」与「定时刷新」
- 同步入口按租户加 RedisLockClient 分布式锁做 single-flight(零等待、租约可配 lockLeaseSeconds),未开启 blade.lock.enabled 时降级执行并告警
- 客商税号白名单明确为购、销双方税号均须命中客商档案(我方主体税号亦在档案中),抽出 taxNoArchived 做空值防护
- 日志补强:每页过滤漏斗明细、镜像无变化数、镜像未匹配登记提示、翻页触顶告警、补偿绑定耗时与远程补查次数、解析失败带发票号码
- Nacos 模板:MySQL 连接串补 rewriteBatchedStatements=true 使 JDBC 批量真正合批;dev/test 开启 blade.lock.enabled,prod 补开启前置条件说明
- SyncKingdeeTest:方法名更正为 syncKingdeeInvoicePool,测试属性关闭 syncEnabled,避免 cron 与手动触发在同一 JVM 并发跑
This commit is contained in:
刘泉佳
2026-09-21 17:31:22 +08:00
parent 9a82ed2eaf
commit b3eee6347b
7 changed files with 363 additions and 75 deletions
@@ -88,7 +88,7 @@ InvoiceReceiptKingdeeSyncTask InvoiceReceiptServiceImpl
- 任务类仿 `PaymentKingdeeResultTask` / `CustomerKingdeeSyncTask`:业务逻辑收敛在 `IInvoiceReceiptService.syncKingdeeInvoicePool()`,任务类只做开关与调度,便于手动触发联调。
- 增量窗口约定:**每轮固定滚动近 N 天**(可配,建议 30 天),不依赖"上次成功时间点"——单轮失败不会漏,同一批数据会被后续每轮反复幂等覆盖。
- **客商税号白名单(购、销双方)**:每轮加载客商档案有效税号(`unifiedCreditCode`,status=1),窗口拉回的发票须**购方税号与销方税号同时命中**该集合才入镜像、参与绑定,白名单外发票不入镜像不绑定;**集合为空不限制**(档案未维护时兼容全量)。`buyerTaxNo`/`salerTaxNo` 均为 rim 契约单值参数,无法按税号集合远程查询,故采用窗口拉取 + 内存过滤的等价实现。
- **客商税号白名单(购、销双方均须命中)**:每轮加载客商档案有效税号(`unifiedCreditCode`,status=1),窗口拉回的发票须**购方税号与销方税号同时命中**该集合才入镜像、参与绑定;我方主体税号同样维护在客商档案中,故进项票要求「购方=我方、销方=建档客商」同时成立。白名单外发票不入镜像不绑定;**集合为空不限制**(档案未维护时兼容全量)。由此推论:**供应商未在客商档案维护统一社会信用代码,其进项票不会进票据池镜像,前端选票也就选不到**——档案维护是同步的前置条件,可用每页过滤明细日志的「客商税号白名单外 N 张」定位漏档。`buyerTaxNo`/`salerTaxNo` 均为 rim 契约单值参数,无法按税号集合远程查询,故采用窗口拉取 + 内存过滤的等价实现。
- **防漏绑补偿**:滚动窗口唯一的漏绑缝隙是「持续失败超过 N 天(窗口滑出 updateTime)」,第 ⑤ 步兜底——仍为未绑定(`kingdeeInvoicePoolId` 为空)的登记按发票号码精确补查 A801-1(优先复用本地镜像,缺失才远程),单轮限量(`compensateLimit`,默认 200,按创建时间先进先出),绑定成功即移出补查队列;金蝶票池暂无该发票时跳过不写库、待后续轮次。**手动同步与补偿绑定不受税号白名单约束**(票由登记侧发起,属我方票)。
- 提供手动触发端点 `POST /invoice-receipt/sync-invoice-pool`(权限码复用 `invoice_receipt_sync`),返回执行摘要,供联调与补偿。
@@ -98,9 +98,9 @@ InvoiceReceiptKingdeeSyncTask InvoiceReceiptServiceImpl
| --- | --- |
| 匹配键 | `invoiceNo`(发票号码)。收票登记侧 `assertInvoiceUnused` 已保证一张发票仅可登记一次,镜像侧按 `invoiceNo` upsert 天然幂等 |
| 发票种类白名单 | `thirdParty.bfi.invoiceReceipt.invoiceTypes`(逗号分隔票易通编码,如 `26,27`)控制可同步的发票种类:白名单外发票**不落镜像不绑定**(手动同步记「发票类型不在同步范围」流水,定时拉取在类型过滤统计中体现);配置为空不限制。定时拉取翻页终止仍按原始页条数判断,不受过滤影响 |
| 客商税号白名单 | 客商档案有效税号(`unifiedCreditCode`,status=1)为白名单,窗口拉回的发票须**购方税号与销方税号同时命中**才入镜像、参与绑定;**集合为空不限制**。与发票种类白名单叠加生效;手动同步与补偿绑定不受此约束 |
| 镜像批量写库 | 同页镜像**一次批量查询**既有记录(`invoiceNo IN 同页号码`)+ `Db.saveBatch` / `Db.updateBatchById` **批量写入**(避免逐条查改 N+1);批量写失败中断本轮,由任务层记录异常、下轮重试(upsert 幂等) |
| 绑定批量落库 | 绑定回填先在**内存**完成(登记实体按页批量查出,票面字段不覆盖),再以**页级事务**批量落库:`updateBatchById` 登记绑定 + `Db.saveBatch` 同步流水(`applyInvoicePoolSyncBatch`,JDBC batch)。批量失败**整页跳过**计入失败统计,下轮任务幂等重试,**无逐单降级路径**(`applyInvoicePoolSyncResult` 仅手动单条同步使用) |
| 客商税号白名单 | 客商档案有效税号(`unifiedCreditCode`,status=1)为白名单,窗口拉回的发票须**购方税号与销方税号同时命中**才入镜像、参与绑定(我方主体税号亦维护在客商档案中);**集合为空不限制**。供应商未建档/未维护统一社会信用代码 → 其进项票不进镜像、前端选票选不到。与发票种类白名单叠加生效;手动同步与补偿绑定不受此约束 |
| 镜像批量写库 | 同页镜像**一次批量查询**既有记录(`invoiceNo IN 同页号码`)+ `Db.saveBatch` / `Db.updateBatchById` **批量写入**(避免逐条查改 N+1);写入前按**票面字段指纹**比对(`attachments_json` 等大字段在内),**无变化的镜像不重写**,避免每轮整窗重写大字段;指纹中空值按「维持库中原值」处理,与 MyBatis-Plus 非空字段更新策略一致,防止源侧空字段每轮误判为变更。批量写失败中断本轮,由任务层记录异常、下轮重试(upsert 幂等) |
| 绑定批量落库 | 绑定回填先在**内存**完成(登记实体按页批量查出,票面字段不覆盖),再逐项比对 `kingdeeInvoicePoolId`/`kingdeeBillNo`/`kingdeeStatus`,**三项均无变化的登记不更新、不计入统计**;有变化的以**页级事务**批量落库:`updateBatchById` 登记绑定 + `Db.saveBatch` 同步流水(`applyInvoicePoolSyncBatch`,JDBC batch)。**流水只在绑定关系变化(原因「定时绑定金蝶票池发票」)或金蝶状态迁移(原因「定时刷新金蝶票池状态」)时写入**,纯流水号回填不刷流水。批量失败**整页跳过**计入失败统计,下轮任务幂等重试,**无逐单降级路径**(`applyInvoicePoolSyncResult` 仅手动单条同步使用) |
| 防漏绑补偿 | 增量窗口为滚动近 N 天,单轮失败不漏;唯一缝隙是「持续失败超 N 天窗口滑出」。补偿绑定步骤对未绑定登记(`kingdeeInvoicePoolId` 为空、非作废)批量补绑:本地镜像**一次批量查询**复用(主流程本轮已落库镜像命中免远程),缺失才按发票号码**精确补查** A801-1(rim 契约无批量号码参数,仅此处逐号远程),新增镜像批量写入 + 绑定页级事务批量落库;单轮限量 `compensateLimit`(默认 200)先进先出,绑定成功即出队;票池暂无则跳过待下轮,不写库不刷流水 |
| 手动同步查询 | A801-1 `invoiceNo` 精确单查(rim 契约单页,返回 0/1 条);多条理论上不存在,取第一条并 warn 日志 |
| 定时绑定范围 | `approvalStatus IN (draft, reviewing, approved, returned)` 且(`kingdeeInvoicePoolId` 为空 或 指向镜像不存在的记录);`voided` 不参与绑定 |
@@ -109,7 +109,7 @@ InvoiceReceiptKingdeeSyncTask InvoiceReceiptServiceImpl
| 金额校验 | 绑定时若票池价税合计与登记 `invoiceAmount` 不一致仅记 warn 日志(跨系统开票金额可能变更),不阻断绑定 |
| 已绑定状态刷新 | 跟随镜像刷新 `kingdeeStatus`;票池 `delete=1`(作废删除)置 `failed`,流水记录原因,**不自动作废**收票登记,人工确认处理 |
| 逐单隔离 | 定时链路无逐单路径:镜像批量查改 + 绑定页级批量落库;仅批量落库异常时整页跳过、下轮幂等重试 |
| 关键日志 | 任务开始(增量窗口 + 发票种类 + 客商税号白名单个数)、每页拉取条数、镜像批量写入条数(新增/更新)、逐条绑定成功(发票号码 + 登记id + 金蝶流水号)、绑定异常(含堆栈)、结束汇总摘要(拉取/类型过滤/客商过滤/镜像新增更新/绑定/状态刷新/失败/补偿绑定) |
| 关键日志 | 任务开始(增量窗口 + 发票种类 + 客商税号白名单个数 + 单页/最大页数)、每页拉取条数(含 BFI 侧远程耗时)、镜像批量写入条数(新增/更新/无变化)、每页落库小结(变更/绑定/状态刷新/流水条数)、绑定成功逐条(发票号码 + 登记id + 金蝶流水号)、落库异常(含页码与堆栈)、翻页触顶 warn(窗口尾部本轮未处理)、补偿小结(补偿绑定数 + 本地镜像命中 + 远程按号补查次数 + 票池无票清单 + 耗时)、锁跳过 warn、未启用分布式锁 warn、结束汇总摘要(翻页/拉取/类型过滤/客商过滤/镜像新增更新/绑定/状态刷新/登记无变化跳过/失败/补偿绑定/耗时) |
### 3.5 状态映射(kingdeeStatus 语义)
@@ -170,6 +170,11 @@ InvoiceReceiptKingdeeSyncTask InvoiceReceiptServiceImpl
| thirdParty.bfi.invoiceReceipt.incrementDays | BFI_INVOICE_RECEIPT_INCREMENT_DAYS | 30 | 首轮/兜底增量窗口天数 |
| thirdParty.bfi.invoiceReceipt.invoiceTypes | BFI_INVOICE_RECEIPT_INVOICE_TYPES | 空 | 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 |
| thirdParty.bfi.invoiceReceipt.compensateLimit | BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT | 200 | 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) |
| thirdParty.bfi.invoiceReceipt.lockLeaseSeconds | BFI_INVOICE_RECEIPT_LOCK_LEASE_SECONDS | 1800 | 全票池同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 |
**数据源前置条件**:`doc/nacos/blade-dev.yaml`、`blade-test.yaml`、`blade-prod.yaml` 的 MySQL 连接串须带 `rewriteBatchedStatements=true`,否则 `Db.saveBatch` / `updateBatchById` 仍是逐条往返、不构成真正的 JDBC 批量(该参数为 MySQL 专有,切换其他数据库需移除)。配置变更后需重启服务生效。
**分布式锁前置条件**:`RedisLockClient` 仅在 `blade.lock.enabled=true` 时装配(该开关无缺省值,不配即关闭)。dev/test 模板已开启,且 `blade.lock.address` 与 `spring.data.redis` 指向同一实例;prod 模板保持关闭,开启前须先补 `blade.lock.password`(与 `spring.data.redis` 一致),否则 Redisson 建连被拒会拖垮依赖该模板的所有服务。锁未启用时全票池同步照常执行,只是失去多副本 single-flight 保护并打 warn 日志。
## 7. 异常处理
@@ -179,7 +184,7 @@ InvoiceReceiptKingdeeSyncTask InvoiceReceiptServiceImpl
| 手动同步按发票号未查到 | 登记 `kingdeeStatus=unsynced`,流水记录「金蝶票池未查询到」,不抛异常中断批量 |
| 绑定时金额不一致 | warn 日志(发票号 + 双方金额),仍按 3.4 口径绑定 |
| 单条绑定/状态刷新异常 | 记录 error 日志(含发票号与堆栈),计数后继续下一单 |
| 定时任务与手动同步并发 | 镜像按 invoiceNo upsert、登记按主键 `updateById`、绑定判重幂等,最终一致 |
| 定时任务与手动同步并发 | 链路二入口按租户加 Redis 分布式锁做 **single-flight**(`RedisLockClient.tryLock(key, REENTRANT, 0, lockLeaseSeconds)`,零等待):多副本调度、手动端点撞上进行中的轮次时,抢不到锁的一侧直接跳过并返回「同步进行中,本轮跳过」,不重复拉取与写库;未开启 `blade.lock.enabled` 时降级为无锁执行并 warn。锁之外仍靠镜像按 invoiceNo upsert、登记按主键更新、绑定判重幂等保证最终一致 |
| A801-1 翻页异常(rim 无总数) | 以「本页条数 < pageSize 或空页」终止,最大 50 页保护,防御死循环 |
## 8. 涉及代码位置
@@ -188,14 +193,14 @@ InvoiceReceiptKingdeeSyncTask InvoiceReceiptServiceImpl
| --- | --- | --- |
| 前端页面 | `tms-erp-web/src/views/payment/invoice-receipt.vue`(handleBatchSync)、`src/api/payment/invoiceReceipt.js`(syncKingdee) | 无需改动(复用现有单条同步接口与按钮) |
| 同步入口改造 | `blade-service/blade-transport/.../service/impl/InvoiceReceiptServiceImpl.java`(syncKingdee 接入 A801-1 查询 + 镜像 upsert + 绑定回填) | 修改 |
| 定时绑定编排 | 同上 `syncKingdeeInvoicePool()`(增量窗口分页拉取 + upsert + 绑定)、`applyInvoicePoolSyncResult()`(逐单独立事务:绑定回填 + 同步流水) | 新增 |
| 定时绑定编排 | 同上 `syncKingdeeInvoicePool()`(RedisLockClient single-flight + 租户锁键)、`executeInvoicePoolSync()`(增量窗口分页拉取 + 票面指纹比对 upsert + 变更比对绑定)、`applyInvoicePoolSyncBatch()`(页级事务:绑定批量更新 + 同步流水批量写入);`applyInvoicePoolSyncResult()` 仅手动单条同步使用 | 新增 |
| Service 接口 | `.../service/IInvoiceReceiptService.java`(syncKingdeeInvoicePool) | 新增 |
| 定时任务 | `blade-service/blade-transport/.../task/InvoiceReceiptKingdeeSyncTask.java` | 新增(仿 PaymentKingdeeResultTask) |
| 手动触发端点 | `.../controller/InvoiceReceiptController.java`(POST /invoice-receipt/sync-invoice-pool) | 新增(可选) |
| 镜像实体 | `blade-transport-api/.../pojo/entity/KingdeeInvoicePool.java`(serialNo/salerTaxNo/buyerTaxNo/checkStatus) | 修改 |
| BFI 客户端 | `blade-third-party-api/blade-bfi-api/.../service/IBfiService.java`(queryInvoicePool)、`pojo/dto/BfiInvoicePoolQueryDataDTO.java`、`pojo/vo/BfiInvoicePoolVO.java` | 无改动复用 |
| SQL | `doc/sql/transport/`(镜像表新增列 + 索引幂等脚本) | 新增 |
| Nacos 配置模板 | `doc/nacos/blade-dev.yaml`、`blade-prod.yaml`、`third-party-api.yaml`(thirdParty.bfi.invoiceReceipt.*) | 新增 |
| Nacos 配置模板 | `doc/nacos/blade-dev.yaml`、`blade-test.yaml`、`blade-prod.yaml`(thirdParty.bfi.invoiceReceipt.*、MySQL `rewriteBatchedStatements`、`blade.lock.enabled`)、`third-party-api.yaml` | 新增 |
## 9. 遗留事项与联调确认