From 562127232bd052b0245ac0882ad37241d0b52fa3 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 10:15:36 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=94=B6=E7=A5=A8=E5=8D=95?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E9=87=91=E8=9D=B6=E5=85=A8=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=EF=BC=88A303-2=E4=BF=9D=E5=AD=98+A302-6=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E5=BA=94=E4=BB=98+A801-3=E5=9B=9E=E5=86=99=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=E9=87=91=E8=9D=B6=E7=A5=A8=E6=B1=A0=E5=AE=9E=E6=97=B6=E6=B8=B8?= =?UTF-8?q?=E6=A0=87=E5=88=86=E9=A1=B5=E9=87=8D=E6=9E=84=EF=BC=8C=E4=B8=8B?= =?UTF-8?q?=E7=BA=BF=E9=95=9C=E5=83=8F=E5=88=86=E9=A1=B5=E4=B8=8E=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E7=BB=91=E5=AE=9A=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pojo/dto/InvoiceReceiptSaveRequest.java | 1 - .../pojo/dto/KingdeeInvoicePoolQueryDTO.java | 32 +- .../transport/pojo/entity/InvoiceReceipt.java | 17 + .../pojo/entity/KingdeeInvoicePool.java | 8 + .../transport/pojo/vo/InvoiceReceiptVO.java | 1 + .../pojo/vo/KingdeeInvoicePoolPageVO.java | 62 + .../pojo/vo/KingdeeInvoicePoolVO.java | 6 +- .../transport/config/BfiProperties.java | 19 + .../constant/KingdeeBfiConstant.java | 18 + .../FormalSettlementController.java | 13 +- .../controller/InvoiceReceiptController.java | 30 +- .../mapper/KingdeeInvoicePoolMapper.java | 24 - .../mapper/KingdeeInvoicePoolMapper.xml | 87 -- .../service/IInvoiceReceiptService.java | 43 +- .../impl/InvoiceReceiptServiceImpl.java | 1160 ++++++++--------- .../KingdeeReceivedInvoiceSyncSupport.java | 325 +++++ .../task/InvoiceReceiptKingdeeSyncTask.java | 70 - .../task/PaymentKingdeeResultTask.java | 23 + .../wrapper/InvoiceReceiptWrapper.java | 6 + .../transport/SyncKingdeeTest.java | 57 - doc/nacos/blade-dev.yaml | 12 - doc/nacos/blade-prod.yaml | 12 - doc/nacos/third-party-api.yaml | 12 - ..._invoice_receipt_kingdee_push_20260922.sql | 94 ++ ...de_kingdee_invoice_pool_prune_20260922.sql | 17 + 25 files changed, 1187 insertions(+), 962 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java delete mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java delete mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java delete mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/SyncKingdeeTest.java create mode 100644 doc/sql/transport/blade_invoice_receipt_kingdee_push_20260922.sql create mode 100644 doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java index 5f73b6c9..e0f12d09 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java @@ -42,7 +42,6 @@ import java.util.List; public class InvoiceReceiptSaveRequest implements Serializable { @Serial private static final long serialVersionUID = 1L; private Long id; - private Long kingdeeInvoicePoolId; private String invoiceNo; private LocalDate invoiceDate; private String invoiceType; 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 c0a01139..535de560 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 @@ -32,12 +32,12 @@ import java.io.Serial; import java.io.Serializable; /** - * 金蝶票据池镜像统一查询条件(收票登记选票与正式结算单认领共用) + * 金蝶票池实时统一查询条件(收票登记选票与正式结算单认领共用) * * @author Chill */ @Data -@Schema(description = "金蝶票据池镜像统一查询条件") +@Schema(description = "金蝶票池实时统一查询条件") public class KingdeeInvoicePoolQueryDTO implements Serializable { @Serial @@ -74,21 +74,9 @@ public class KingdeeInvoicePoolQueryDTO implements Serializable { private String invoiceDateEnd; /** - * 开票单位(销方名称),模糊 + * 发票类型编码(票易通字典,如 26/27) */ - @Schema(description = "开票单位(销方名称,模糊)") - private String salerName; - - /** - * 受票单位(购方名称),模糊 - */ - @Schema(description = "受票单位(购方名称,模糊)") - private String buyerName; - - /** - * 发票类型名称,模糊(镜像存中文名) - */ - @Schema(description = "发票类型名称(模糊)") + @Schema(description = "发票类型编码(票易通字典,如 26/27)") private String invoiceType; /** @@ -104,14 +92,8 @@ public class KingdeeInvoicePoolQueryDTO implements Serializable { private String buyerTaxNo; /** - * 认领过滤:true 仅已认领,false 仅未认领,null 不过滤(已认领=存在正式结算单发票行) + * 单据使用状态[1未用,30在用,60已用,65已入账],空不限(A801-1 原生条件) */ - @Schema(description = "认领过滤:true已认领/false未认领/null不限") - private Boolean claimed; - - /** - * 登记过滤:true 仅已登记,false 仅未登记,null 不过滤(已登记=存在非作废收票登记) - */ - @Schema(description = "登记过滤:true已登记/false未登记/null不限") - private Boolean registered; + @Schema(description = "单据使用状态[1未用,30在用,60已用,65已入账]") + private String expenseStatus; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java index 6e96c2ee..7c9546bc 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java @@ -36,6 +36,7 @@ import org.springblade.core.tenant.mp.TenantEntity; import java.io.Serial; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; /** * 收票登记实体类 @@ -151,10 +152,26 @@ public class InvoiceReceipt extends TenantEntity { * 金蝶单据号(绑定票池后取发票流水号 serialNo) */ private String kingdeeBillNo; + /** + * 金蝶收票单单据编号(A303-2 批量保存生成/幂等预检命中回填) + */ + private String kingdeeReceivedInvoiceNo; /** * 金蝶同步状态(unsynced 未同步 / synced 已同步 / failed 同步失败) */ private String kingdeeStatus; + /** + * 收票单推送状态(unsynced 未推送 / pushed 已保存收票单 / assigned 已指定应付 / failed 推送失败) + */ + private String kingdeePushStatus; + /** + * 收票单推送失败原因(金蝶侧删除/红冲/查验不通过等异常提示人工) + */ + private String kingdeePushMsg; + /** + * 最近一次收票单推送/回写时间 + */ + private LocalDateTime kingdeePushTime; /** * 附件信息JSON */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java index 89d9e2e8..446d8d86 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java @@ -63,6 +63,10 @@ public class KingdeeInvoicePool extends TenantEntity { * 发票类型(票易通编码转名称后落库,如「数电发票(增值税专用发票)」) */ private String invoiceType; + /** + * 票易通原始发票类型编码(A801-1 invoiceType,如 26/27,A303-2 invoicetypef7_number 取数) + */ + private String invoiceTypeCode; /** * 税率(百分数,13 表示 13%) */ @@ -131,6 +135,10 @@ public class KingdeeInvoicePool extends TenantEntity { * 附件信息JSON(结构 {"source":"piaozone","path":"A801-2下载path","name":"文件名"}) */ private String attachmentsJson; + /** + * A801-1 发票明细行JSON(BfiInvoicePoolItemVO 列表,A303-2 收票单分录取数来源) + */ + private String itemsJson; /** * 票池数据更新时间(A801-1 billCreateTime,镜像排序字段) */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java index 8be4dcf2..eec9c0ad 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java @@ -49,6 +49,7 @@ public class InvoiceReceiptVO extends InvoiceReceipt { @TableField(exist = false) private String settlementNos; @TableField(exist = false) private String approvalStatusName; @TableField(exist = false) private String kingdeeStatusName; + @TableField(exist = false) private String kingdeePushStatusName; @TableField(exist = false) private String createUserName; @TableField(exist = false) private String updateUserName; @TableField(exist = false) private List settlements; 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 new file mode 100644 index 00000000..d2c1d8e5 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolPageVO.java @@ -0,0 +1,62 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 金蝶票池实时查询游标分页返回体(A801-1 无总数:hasMore 驱动上一页/下一页) + * + * @author Chill + */ +@Data +@Schema(description = "金蝶票池游标分页返回体") +public class KingdeeInvoicePoolPageVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 本页数据(金蝶实时行 + 本地认领/登记装饰标记) */ + @Schema(description = "本页数据") + private List records; + + /** 当前页码,从 1 开始 */ + @Schema(description = "当前页码") + private Long current; + + /** 每页条数 */ + @Schema(description = "每页条数") + private Long size; + + /** 是否还有下一页(服务端多取 1 条探测) */ + @Schema(description = "是否还有下一页") + private Boolean hasMore; +} 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 1da543d6..e20bcb89 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 @@ -46,7 +46,7 @@ public class KingdeeInvoicePoolVO implements Serializable { @Serial private static final long serialVersionUID = 1L; - /** 镜像主键,收票登记保存绑定用(kingdeeInvoicePoolId) */ + /** 镜像主键,仅已入镜像(已登记)的票非空 */ @Schema(description = "镜像主键") @JsonSerialize(using = ToStringSerializer.class) private Long id; @@ -143,11 +143,11 @@ public class KingdeeInvoicePoolVO implements Serializable { @Schema(description = "默认认领金额") private BigDecimal matchedAmount; - /** 是否已被正式结算单认领 */ + /** 是否已被正式结算单认领(实时查询返回行的本地装饰标记,仅展示不作过滤) */ @Schema(description = "是否已认领") private Boolean claimed; - /** 是否已存在非作废收票登记 */ + /** 是否已存在非作废收票登记(实时查询返回行的本地装饰标记,仅展示不作过滤) */ @Schema(description = "是否已登记") private Boolean registered; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java index 0c5e1d55..75650185 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java @@ -60,6 +60,11 @@ public class BfiProperties { */ private InvoiceApply invoiceApply = new InvoiceApply(); + /** + * 收票单推送配置(A303-2 收票单批量保存 / A302-6 收票指定应付 / A801-3 收票单查询回写) + */ + private ReceivedInvoice receivedInvoice = new ReceivedInvoice(); + /** * 付款申请推送配置项 */ @@ -95,4 +100,18 @@ public class BfiProperties { private String srcType = "0KzXs"; } + /** + * 收票单推送配置项(A303-2 / A302-6 / A801-3): + * 发票类型编码、税收分类编码、购销方税号地址银行均取票池镜像与明细,不走配置 + */ + @Data + public static class ReceivedInvoice { + + /** + * A303-2 分录物料编码:联调期临时值(金蝶测试档案),发票明细无金蝶物料概念, + * 真实物料口径与金蝶对齐后替换(与应收/应付联调临时值同源) + */ + private String materialNumber = "SP0324"; + } + } 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 7f6deaa8..72796707 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 @@ -152,6 +152,24 @@ public final class KingdeeBfiConstant { /** 金蝶同步状态:生成失败(推送异常/金蝶返回失败,可重推)。 */ public static final String KINGDEE_STATUS_FAILED = "failed"; + /** 收票单推送状态:未推送(新建单据默认,票池绑定层的 kingdee_status 与此无关)。 */ + public static final String RECEIVED_PUSH_UNSYNCED = "unsynced"; + + /** 收票单推送状态:金蝶收票单已保存(A303-2 成功,等待/已完成指定应付)。 */ + public static final String RECEIVED_PUSH_PUSHED = "pushed"; + + /** 收票单推送状态:已指定应付(A302-6 成功,一票一应付,终态;金蝶侧异常回收获占)。 */ + public static final String RECEIVED_PUSH_ASSIGNED = "assigned"; + + /** 收票单推送状态:推送失败(保存/指定异常或金蝶侧删除红冲,人工处理后重推)。 */ + public static final String RECEIVED_PUSH_FAILED = "failed"; + + /** A303-2 业务类型(PUR:采购 / FEE:费用,TMS 服务费进项票暂按采购)。待确认:取值需金蝶侧最终确认。 */ + public static final String BFI_RECEIVED_BIZ_TYPE = "PUR"; + + /** A303-2 必填:外部单据类型(收票单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */ + public static final String BFI_RECEIVED_INVOICE_SRC_TYPE = "TMS收票登记"; + /** A103-2 付款状态:已付款。 */ public static final String BFI_PAY_STATUS_ALREADY_PAY = "Alreadypay"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java index fffcffff..4ad3efc1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java @@ -30,7 +30,7 @@ import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest; import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.dto.PreSettlementDetailAdjustRequest; import org.springblade.transport.pojo.entity.FormalSettlementDetailFee; -import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; +import org.springblade.transport.pojo.vo.KingdeeInvoicePoolPageVO; import org.springblade.transport.pojo.vo.FormalSettlementVO; import org.springblade.transport.excel.FormalSettlementExcel; import org.springblade.transport.pojo.vo.PreSettlementVO; @@ -262,15 +262,8 @@ public class FormalSettlementController extends BladeController { @GetMapping("/invoice-pool") @ApiOperationSupport(order = 21) - @Operation(summary = "金蝶票据池镜像分页查询(认领查询)") - public R> invoicePool(KingdeeInvoicePoolQueryDTO query) { + @Operation(summary = "金蝶票池实时分页查询(认领查询)") + public R invoicePool(KingdeeInvoicePoolQueryDTO query) { return R.data(invoiceReceiptService.invoicePoolPage(query)); } - - @PostMapping("/invoice-pool/fetch") - @ApiOperationSupport(order = 22) - @Operation(summary = "按发票号码从金蝶票池补查单张发票") - public R fetchInvoicePool(@RequestParam String invoiceNo) { - return R.data(invoiceReceiptService.fetchKingdeeInvoiceByNo(invoiceNo)); - } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java index 413825df..1ffc914b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java @@ -39,7 +39,7 @@ import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.vo.InvoiceReceiptVO; -import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; +import org.springblade.transport.pojo.vo.KingdeeInvoicePoolPageVO; import org.springblade.transport.service.IInvoiceReceiptService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -81,18 +81,11 @@ public class InvoiceReceiptController extends BladeController { @GetMapping("/invoice-pool") @ApiOperationSupport(order = 3) - @Operation(summary = "金蝶票据池镜像分页查询(含认领/登记标记)") - public R> invoicePool(KingdeeInvoicePoolQueryDTO query) { + @Operation(summary = "金蝶票池实时分页查询(含认领/登记标记)") + public R invoicePool(KingdeeInvoicePoolQueryDTO query) { return R.data(invoiceReceiptService.invoicePoolPage(query)); } - @PostMapping("/invoice-pool/fetch") - @ApiOperationSupport(order = 14) - @Operation(summary = "按发票号码从金蝶票池补查单张发票") - public R fetchInvoicePool(@RequestParam String invoiceNo) { - return R.data(invoiceReceiptService.fetchKingdeeInvoiceByNo(invoiceNo)); - } - @GetMapping("/settlement-candidates") @ApiOperationSupport(order = 4) @Operation(summary = "可关联的应付正式结算单") @@ -163,10 +156,17 @@ public class InvoiceReceiptController extends BladeController { return R.data(invoiceReceiptService.syncKingdee(id)); } - @PostMapping("/sync-invoice-pool") - @ApiOperationSupport(order = 13) - @Operation(summary = "手动触发金蝶全票池发票同步绑定") - public R syncInvoicePool() { - return R.data(invoiceReceiptService.syncKingdeeInvoicePool()); + @PostMapping("/push-kingdee") + @ApiOperationSupport(order = 15) + @Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存 + A302-6 收票指定应付)") + public R pushKingdee(@RequestBody List ids) { + return R.data(invoiceReceiptService.pushKingdeeReceivedInvoice(ids)); + } + + @PostMapping("/sync-kingdee-result") + @ApiOperationSupport(order = 16) + @Operation(summary = "手动触发金蝶收票单结果回写(A801-3 异常回收/指定补偿)") + public R syncKingdeeResult() { + return R.data(invoiceReceiptService.syncKingdeeReceivedInvoiceResult()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java index 241517d9..69663196 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.java @@ -26,12 +26,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baomidou.mybatisplus.core.metadata.IPage; import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.entity.KingdeeInvoicePool; -import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; /** * 金蝶进项发票票据池 Mapper 接口 @@ -41,24 +37,4 @@ import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; @Mapper public interface KingdeeInvoicePoolMapper extends BaseMapper { - /** - * 金蝶票据池镜像统一分页查询:认领/登记标记由 EXISTS 子查询直出 - * - * @param page 分页参数 - * @param query 统一查询条件(已在上层完成 trim 与日期格式校验) - * @param tenantId 当前租户 - * @return 统一视图分页 - */ - IPage selectInvoicePoolPage(IPage page, - @Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId); - - /** - * 按发票号码查询单张镜像统一视图(补查落库后回读) - * - * @param invoiceNo 发票号码 - * @param tenantId 当前租户 - * @return 统一视图,无镜像时 null - */ - KingdeeInvoicePoolVO selectInvoicePoolVO(@Param("invoiceNo") String invoiceNo, - @Param("tenantId") String tenantId); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml deleted file mode 100644 index 72314c70..00000000 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/KingdeeInvoicePoolMapper.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - EXISTS (SELECT 1 FROM blade_formal_settlement_invoice c - WHERE c.is_deleted = 0 AND c.tenant_id = p.tenant_id AND c.invoice_no = p.invoice_no - AND EXISTS (SELECT 1 FROM blade_formal_settlement s - WHERE s.id = c.formal_settlement_id AND s.is_deleted = 0 - AND s.approval_status != 'voided')) - - - - - EXISTS (SELECT 1 FROM blade_invoice_receipt r - WHERE r.is_deleted = 0 AND r.tenant_id = p.tenant_id - AND r.approval_status != 'voided' AND r.invoice_no = p.invoice_no) - - - - - p.id, p.invoice_no, p.serial_no, - DATE_FORMAT(p.invoice_date, '%Y-%m-%d') AS invoice_date, - p.invoice_type, p.tax_rate, p.invoice_amount, p.tax_amount, - p.receiver_name, p.buyer_tax_no, p.issuer_name, p.saler_tax_no, - p.bank_name, p.bank_account, p.issuing_bank, p.phone, - p.customer_emails, p.department_emails, - p.kingdee_bill_no, p.kingdee_status, p.check_status, p.attachments_json, - p.invoice_amount AS available_invoice_amount, - p.invoice_amount AS matched_amount, - AS claimed, - AS registered - - - - - - diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java index bd959bd8..b1d2045a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java @@ -31,10 +31,9 @@ import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.entity.InvoiceReceipt; -import org.springblade.transport.pojo.entity.InvoiceReceiptRecord; import org.springblade.transport.pojo.entity.KingdeeInvoicePool; import org.springblade.transport.pojo.vo.InvoiceReceiptVO; -import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; +import org.springblade.transport.pojo.vo.KingdeeInvoicePoolPageVO; import java.util.List; import java.util.Map; @@ -51,21 +50,14 @@ public interface IInvoiceReceiptService extends BaseService { InvoiceReceiptVO detail(Long id); /** - * 金蝶票据池镜像分页查询(含本地认领/登记标记),收票登记选票与正式结算单认领共用 + * 金蝶票池实时统一查询(A801-1 游标分页 + 本地认领/登记装饰标记),收票登记选票与正式结算单认领共用 + *

A801-1 不返回总数,改用「多取 1 条探测」得出 hasMore 驱动上一页/下一页; + * claimed/registered 仅本地展示装饰,不下推金蝶过滤。 * * @param query 统一查询条件 - * @return 镜像分页 + * @return 实时游标分页 */ - IPage invoicePoolPage(KingdeeInvoicePoolQueryDTO query); - - /** - * 按发票号码从金蝶票池精确补查单张发票:与定时同步同一套白名单校验, - * 通过后 upsert 入镜像并返回;不受增量时间窗口约束 - * - * @param invoiceNo 发票号码 - * @return 镜像统一视图 - */ - KingdeeInvoicePoolVO fetchKingdeeInvoiceByNo(String invoiceNo); + KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query); List> settlementCandidates(String keyword, Long receiptId); @@ -86,22 +78,23 @@ public interface IInvoiceReceiptService extends BaseService { String syncKingdee(Long id); /** - * 定时查询金蝶全票池发票(A801-1)并绑定收票登记 - *

- * 按租户加分布式锁做 single-flight(需开启 {@code blade.lock.enabled}):多副本并发触发、 - * 或手动触发撞上进行中的轮次时,抢不到锁的一轮直接跳过并返回提示,不重复拉取与写库。 + * 批量推送金蝶收票单(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付)。 + *

逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、恰好分摊一张应付结算单)→ + * A102-4 预检财务应付单已生成 → A801-3 幂等预检(命中跳过保存续推指定)→ A303-2 保存 → + * 立即 A302-6 指定应付;成功置 assigned 并锁单,失败置 failed 提示人工处理后重推。 * - * @return 执行摘要,含拉取页数、镜像新增/更新、绑定与状态刷新统计 + * @param ids 收票登记id列表 + * @return 执行摘要(逐单失败原因) */ - String syncKingdeeInvoicePool(); + String pushKingdeeReceivedInvoice(List ids); /** - * 批量应用金蝶票池绑定结果(页级事务):登记绑定批量更新 + 同步流水批量写入,整页原子生效 + * 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记); + * pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号 * - * @param receipts 变更比对后需更新、且已回填绑定字段的收票登记 - * @param records 同步流水,绑定关系变化或金蝶状态迁移时才写,可为 receipts 的子集 + * @return 执行摘要 */ - void applyInvoicePoolSyncBatch(List receipts, List records); + String syncKingdeeReceivedInvoiceResult(); /** * 应用金蝶票池同步结果:回填收票登记绑定关系与同步状态(独立事务,仅手动单条同步使用) @@ -109,7 +102,7 @@ public interface IInvoiceReceiptService extends BaseService { * @param receipt 收票登记实体 * @param invoice upsert 后的票据池镜像记录,null 表示金蝶票池未查询到 * @param missReason 未命中原因,命中时可为空 - * @param rebind 是否强制重新绑定镜像(手动同步 true) + * @param rebind 是否强制重新绑定镜像(定时绑定链路退役后唯一调用方手动同步恒传 true,形参保守保留) * @return 金蝶流水号(serialNo),未命中返回 null */ String applyInvoicePoolSyncResult(InvoiceReceipt receipt, KingdeeInvoicePool invoice, String missReason, 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 77267f19..477266a1 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 @@ -28,14 +28,10 @@ package org.springblade.transport.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.toolkit.Db; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; -import org.springblade.core.redis.lock.LockType; -import org.springblade.core.redis.lock.RedisLockClient; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.Func; @@ -47,11 +43,14 @@ import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO; import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO; 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.constant.KingdeeBfiConstant; import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.CustomerArchiveMapper; import org.springblade.transport.mapper.CustomerContactMapper; +import org.springblade.transport.mapper.FormalSettlementDetailMapper; +import org.springblade.transport.mapper.FormalSettlementInvoiceMapper; import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.InvoiceReceiptMapper; import org.springblade.transport.mapper.InvoiceReceiptRecordMapper; @@ -60,18 +59,24 @@ import org.springblade.transport.mapper.KingdeeInvoicePoolMapper; import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; 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.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; import org.springblade.transport.pojo.entity.KingdeeInvoicePool; import org.springblade.transport.pojo.vo.InvoiceReceiptVO; +import org.springblade.transport.pojo.vo.KingdeeInvoicePoolPageVO; import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; import org.springblade.transport.service.IInvoiceReceiptService; +import org.springblade.transport.service.KingdeeSyncContext; +import org.springblade.transport.support.KingdeeApBillSyncSupport; +import org.springblade.transport.support.KingdeeReceivedInvoiceSyncSupport; import org.springblade.transport.wrapper.InvoiceReceiptWrapper; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -80,10 +85,8 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -91,7 +94,6 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -109,36 +111,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl> POOL_FINGERPRINT_FIELDS = List.of( - KingdeeInvoicePool::getInvoiceNo, - KingdeeInvoicePool::getSerialNo, - KingdeeInvoicePool::getKingdeeBillNo, - KingdeeInvoicePool::getKingdeeStatus, - KingdeeInvoicePool::getInvoiceDate, - KingdeeInvoicePool::getInvoiceType, - KingdeeInvoicePool::getTaxRate, - KingdeeInvoicePool::getInvoiceAmount, - KingdeeInvoicePool::getTaxAmount, - KingdeeInvoicePool::getReceiverName, - KingdeeInvoicePool::getBuyerTaxNo, - KingdeeInvoicePool::getIssuerName, - KingdeeInvoicePool::getSalerTaxNo, - KingdeeInvoicePool::getCheckStatus, - KingdeeInvoicePool::getAttachmentsJson, - KingdeeInvoicePool::getSourceUpdatedTime); - - /** 定时绑定 single-flight 锁键前缀,按租户区分,同租户同一时刻只允许一轮全票池同步 */ - private static final String POOL_SYNC_LOCK_PREFIX = "transport:invoice-receipt:kingdee-pool-sync:"; - /** A801-1 查询限定发票状态:0 正常(票易通字典),作废/异常票不进镜像、不可补查 */ private static final String POOL_INVOICE_STATUS_NORMAL = "0"; @@ -146,30 +118,22 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl redisLockClientProvider; + private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport; + private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport; - /** A801-1 增量窗口天数(按票池更新时间),首轮/兜底全量比对窗口 */ - @Value("${thirdParty.bfi.invoiceReceipt.incrementDays:30}") - private int incrementDays; + /** A801-3 推送幂等预检回看窗口天数(金蝶修改时间窗口,契约约束不超过 1 个月) */ + @Value("${thirdParty.bfi.receivedInvoice.queryWindowDays:30}") + private int receivedQueryWindowDays; - /** A801-1 同步发票种类白名单(逗号分隔票易通编码,如 26,27),为空不限制 */ - @Value("${thirdParty.bfi.invoiceReceipt.invoiceTypes:}") - private String invoiceTypes; - - /** 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) */ - @Value("${thirdParty.bfi.invoiceReceipt.compensateLimit:200}") - private int compensateLimit; - - /** - * 全票池同步 single-flight 锁租约秒数,须大于最坏一轮耗时(翻页远程 + 补偿补查), - * 否则租约到期后另一副本会并发起跑;进程异常退出时锁在租约到期后自动释放 - */ - @Value("${thirdParty.bfi.invoiceReceipt.lockLeaseSeconds:1800}") - private long syncLockLeaseSeconds; + /** A801-3 定时回写轮询窗口天数(同样受金蝶 1 个月窗口约束) */ + @Value("${thirdParty.bfi.receivedInvoice.resultWindowDays:30}") + private int receivedResultWindowDays; @Override public IPage selectPage(IPage page, InvoiceReceiptVO query) { @@ -182,6 +146,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl invoicePoolPage(KingdeeInvoicePoolQueryDTO query) { + public KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query) { long current = query.getCurrent() == null ? 1L : Math.max(query.getCurrent(), 1L); long size = query.getSize() == null ? 10L : Math.min(Math.max(query.getSize(), 1L), 100L); - query.setCurrent(current); - query.setSize(size); - query.setInvoiceNo(trimOrNull(query.getInvoiceNo())); - query.setSalerName(trimOrNull(query.getSalerName())); - query.setBuyerName(trimOrNull(query.getBuyerName())); - query.setInvoiceType(trimOrNull(query.getInvoiceType())); - query.setSalerTaxNo(trimOrNull(query.getSalerTaxNo())); - query.setBuyerTaxNo(trimOrNull(query.getBuyerTaxNo())); - query.setInvoiceDateStart(normalizeQueryDate(query.getInvoiceDateStart())); - query.setInvoiceDateEnd(normalizeQueryDate(query.getInvoiceDateEnd())); - Page page = new Page<>(current, size); - return invoicePoolMapper.selectInvoicePoolPage(page, query, AuthUtil.getTenantId()); + // 查询票号(已 trim 归一,方法级可见):用于对远程模糊命中做全等防御 + String queryInvoiceNo = trimOrNull(query.getInvoiceNo()); + BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO(); + data.setPageNo((int) current); + // A801-1 无分页总数:多取 1 条探测下一页,返回前截断 + data.setPageSize((int) size + 1); + // 固定只认正常状态票,作废/异常票不进选票结果 + data.setInvoiceStatus(POOL_INVOICE_STATUS_NORMAL); + data.setInvoiceNo(queryInvoiceNo); + data.setSalerTaxNo(trimOrNull(query.getSalerTaxNo())); + data.setBuyerTaxNo(trimOrNull(query.getBuyerTaxNo())); + data.setInvoiceType(trimOrNull(query.getInvoiceType())); + data.setExpenseStatus(trimOrNull(query.getExpenseStatus())); + data.setInvoiceDateStart(normalizeQueryDate(query.getInvoiceDateStart())); + data.setInvoiceDateEnd(normalizeQueryDate(query.getInvoiceDateEnd())); + BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); + param.setQueryParam(data); + BfiPageVO 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; + List records = pageRows.stream().map(this::toPoolViewVO).toList(); + 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); + return result; } - /** 空白串归一为 null,便于 XML 的 != '' 判空条件 */ + /** 空白串归一为 null,便于远程查询条件与 MyBatis-Plus 判空 */ private String trimOrNull(String text) { return StringUtil.isBlank(text) ? null : text.trim(); } - /** 查询日期清洗:空转 null,格式非法直接报业务异常(自定义 SQL 无类型保护,先校验再进 XML) */ + /** 查询日期清洗:空转 null,格式非法直接报业务异常(远端只认 yyyy-MM-dd,先校验再下发) */ private String normalizeQueryDate(String text) { if (StringUtil.isBlank(text)) { return null; @@ -243,43 +235,67 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl poolRows = queryPoolByInvoiceNo(trimmedInvoiceNo); - BfiInvoicePoolVO poolRow = poolRows.stream() - .filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()) - && trimmedInvoiceNo.equals(row.getInvoiceNo().trim())) - .findFirst() - .orElse(null); - if (poolRow == null) { - throw new ServiceException("金蝶票池未查询到发票 " + trimmedInvoiceNo); - } - if (filterByInvoiceTypes(List.of(poolRow)).isEmpty()) { - throw new ServiceException("发票 " + trimmedInvoiceNo + " 的票种不在同步白名单内,不能补查"); - } - if (filterByArchiveTaxNos(customerTaxNoSet(), List.of(poolRow)).isEmpty()) { - throw new ServiceException("发票 " + trimmedInvoiceNo - + " 购销双方存在未建档税号,请先在客商档案维护统一社会信用代码后再补查"); - } - // upsert 自带票面指纹比对,无变化不重写,重复点击安全;随后从镜像回读统一视图(含认领/登记标记) - batchUpsertInvoicePool(List.of(poolRow)); - KingdeeInvoicePoolVO vo = invoicePoolMapper.selectInvoicePoolVO(trimmedInvoiceNo, AuthUtil.getTenantId()); - if (vo == null) { - throw new ServiceException("补查失败:镜像回读不到发票 " + trimmedInvoiceNo); - } + /** + * A801-1 实时行 -> 统一视图:镜像未生成故 id 为空;金额口径价税合计三值同源; + * 银行/邮箱/电话远端无来源保持空,与镜像存根同形 + */ + private KingdeeInvoicePoolVO toPoolViewVO(BfiInvoicePoolVO row) { + KingdeeInvoicePoolVO vo = new KingdeeInvoicePoolVO(); + vo.setInvoiceNo(row.getInvoiceNo().trim()); + vo.setSerialNo(row.getSerialNo()); + vo.setInvoiceDate(row.getInvoiceDate()); + vo.setInvoiceType(BfiConstant.INVOICE_TYPE_NAMES.getOrDefault(row.getInvoiceType(), row.getInvoiceType())); + vo.setTaxRate(resolveTaxRate(row)); + vo.setTaxAmount(parseAmount(row.getInvoiceNo(), row.getTaxAmount())); + BigDecimal totalAmount = parseAmount(row.getInvoiceNo(), row.getTotalAmount()); + vo.setInvoiceAmount(totalAmount); + vo.setAvailableInvoiceAmount(totalAmount); + vo.setMatchedAmount(totalAmount); + vo.setReceiverName(row.getBuyerName()); + vo.setBuyerTaxNo(row.getBuyerTaxNo()); + vo.setIssuerName(row.getSalerName()); + vo.setSalerTaxNo(row.getSalerTaxNo()); + vo.setKingdeeBillNo(row.getSerialNo()); + vo.setCheckStatus(row.getCheckStatus()); + vo.setAttachmentsJson(buildAttachmentJson(row)); return vo; } + /** + * claimed/registered 只读装饰:本页票号三次批量本地查询内存打标,金蝶不感知、不作过滤下推; + * claimed=票关联的结算单中存在未作废主单(作废结算单不占坑) + */ + private void decorateUseFlags(List records) { + if (records.isEmpty()) { + return; + } + List invoiceNos = records.stream().map(KingdeeInvoicePoolVO::getInvoiceNo).distinct().toList(); + Set registeredNos = list(Wrappers.lambdaQuery() + .select(InvoiceReceipt::getInvoiceNo) + .in(InvoiceReceipt::getInvoiceNo, invoiceNos) + .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED) + .eq(InvoiceReceipt::getIsDeleted, 0)).stream() + .map(InvoiceReceipt::getInvoiceNo).collect(Collectors.toSet()); + Map> claimIdsByInvoice = formalSettlementInvoiceMapper.selectList( + Wrappers.lambdaQuery() + .select(FormalSettlementInvoice::getInvoiceNo, FormalSettlementInvoice::getFormalSettlementId) + .in(FormalSettlementInvoice::getInvoiceNo, invoiceNos)).stream() + .collect(Collectors.groupingBy(FormalSettlementInvoice::getInvoiceNo, + Collectors.mapping(FormalSettlementInvoice::getFormalSettlementId, Collectors.toSet()))); + Set allClaimIds = claimIdsByInvoice.values().stream().flatMap(Set::stream).collect(Collectors.toSet()); + Set aliveSettlementIds = allClaimIds.isEmpty() ? Set.of() + : formalSettlementMapper.selectList(Wrappers.lambdaQuery() + .select(FormalSettlement::getId) + .in(FormalSettlement::getId, allClaimIds) + .ne(FormalSettlement::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() + .map(FormalSettlement::getId).collect(Collectors.toSet()); + records.forEach(record -> { + record.setRegistered(registeredNos.contains(record.getInvoiceNo())); + record.setClaimed(claimIdsByInvoice.getOrDefault(record.getInvoiceNo(), Set.of()).stream() + .anyMatch(aliveSettlementIds::contains)); + }); + } + @Override public List> settlementCandidates(String keyword, Long receiptId) { List settlements = formalSettlementMapper.selectList( @@ -354,8 +370,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl oldSettlementIds = entity.getId() == null ? List.of() : relationSettlementIds(entity.getId()); - KingdeeInvoicePool invoice = lockedInvoice(request.getKingdeeInvoicePoolId()); - if (invoice == null) invoice = invoiceSnapshot(request); + KingdeeInvoicePool invoice = persistMirrorFromRemote(request.getInvoiceNo()); assertInvoiceUnused(invoice, entity.getId()); Map requestedRows = distinctSettlementRows( @@ -466,6 +481,12 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl poolInvoices = queryPoolByInvoiceNo(entity.getInvoiceNo()); + log.info("收票登记[{}]同步金蝶票池查询完成:命中 {} 条", entity.getInvoiceNo(), poolInvoices.size()); if (poolInvoices.size() > 1) { log.warn("收票登记[{}]同步金蝶票池命中 {} 条,取第一条", entity.getInvoiceNo(), poolInvoices.size()); } @@ -486,303 +511,418 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl ids) { + if (Func.isEmpty(ids)) { + throw new ServiceException("请选择需要推送金蝶的收票登记"); } - String lockKey = POOL_SYNC_LOCK_PREFIX + currentTenant(); - boolean locked = false; - try { - // 零等待抢锁:已有轮次在跑就立刻放弃本轮,多副本与手动触发共用这一道闸 - locked = redisLockClient.tryLock(lockKey, LockType.REENTRANT, 0, syncLockLeaseSeconds, TimeUnit.SECONDS); - if (!locked) { - log.warn("金蝶全票池同步已有轮次进行中,本轮跳过,锁键={}", lockKey); - return "金蝶全票池同步进行中,本轮跳过"; - } - return executeInvoicePoolSync(); - } catch (InterruptedException interruptedException) { - Thread.currentThread().interrupt(); - throw new ServiceException("等待金蝶全票池同步分布式锁被中断"); - } finally { - if (locked) { - redisLockClient.unLock(lockKey, LockType.REENTRANT); + List distinctIds = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (distinctIds.isEmpty()) { + throw new ServiceException("请选择需要推送金蝶的收票登记"); + } + List receipts = listByIds(distinctIds); + if (receipts.size() != distinctIds.size()) { + throw new ServiceException("部分收票登记不存在或已删除"); + } + // 批量组装推送计划:分摊关系/镜像/结算单/结算明细一次取回,避免逐单查库 N+1 + Map> relationsByReceipt = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceReceiptSettlement::getInvoiceReceiptId, distinctIds) + .orderByAsc(InvoiceReceiptSettlement::getCreateTime)).stream() + .collect(Collectors.groupingBy(InvoiceReceiptSettlement::getInvoiceReceiptId)); + List mirrorIds = receipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId) + .filter(Objects::nonNull).distinct().toList(); + Map mirrorById = mirrorIds.isEmpty() ? Map.of() + : invoicePoolMapper.selectBatchIds(mirrorIds).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first)); + List settlementIds = relationsByReceipt.values().stream().flatMap(List::stream) + .map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + Map settlementById = settlementIds.isEmpty() ? Map.of() + : formalSettlementMapper.selectBatchIds(settlementIds).stream() + .collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first)); + List payableSettlementIds = settlementById.values().stream() + .filter(settlement -> "payable".equals(settlement.getSettlementType())) + .map(FormalSettlement::getId).toList(); + Map> detailsBySettlement = payableSettlementIds.isEmpty() ? Map.of() + : formalSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, payableSettlementIds) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)); + Map planByReceipt = new LinkedHashMap<>(); + for (InvoiceReceipt receipt : receipts) { + planByReceipt.put(receipt.getId(), buildReceivedInvoicePushPlan(receipt, + relationsByReceipt.getOrDefault(receipt.getId(), List.of()), mirrorById, settlementById, detailsBySettlement)); + } + // A102-4 应付单已生成预检:本批防重键一次批量查询,缺单的登记逐单提示先完成付款侧同步 + List apSrcCodes = planByReceipt.values().stream() + .filter(plan -> plan.blockedReason() == null) + .map(ReceivedInvoicePushPlan::apBillSrcCode).filter(Func::isNotBlank).distinct().toList(); + Map apBillNoBySrcCode = apSrcCodes.isEmpty() ? Map.of() + : kingdeeApBillSyncSupport.queryApBillExistNos(apSrcCodes); + // A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存续推指定) + Map kingdeeRowByInvoiceNo = queryKingdeeReceivedInvoiceRows(planByReceipt); + KingdeeSyncContext context = KingdeeSyncContext.empty(); + int success = 0; + List failures = new ArrayList<>(); + for (InvoiceReceipt receipt : receipts) { + try { + pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context); + success++; + } catch (RuntimeException pushException) { + failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + + ":" + pushException.getMessage()); + log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException); } } - } - - /** - * 金蝶全票池同步主体:增量窗口分页拉取 + 镜像批量 upsert + 绑定回填 + 漏绑补偿, - * 由 {@link #syncKingdeeInvoicePool()} 在分布式锁保护下调用 - * - * @return 执行摘要 - */ - private String executeInvoicePoolSync() { - // ① A801-1 增量窗口分页拉取(票池更新时间窗口,rim 契约不返回总数,按"本页不满页即末页"终止); - long startMillis = System.currentTimeMillis(); - LocalDateTime windowStart = LocalDateTime.now().minusDays(incrementDays); - LocalDateTime windowEnd = LocalDateTime.now(); - IInvoiceReceiptService self = self(); - // 以下为本轮统计口径,全部累计整轮并进入结束摘要,用于观测拉取量、过滤量与写库量 - // A801-1 原始拉取张数,未经任何白名单过滤 - int pulledCount = 0; - // 发票种类白名单过滤掉的张数(增量窗口内非同步范围的票) - int filteredCount = 0; - // 客商税号白名单过滤掉的张数(购、销双方税号任一未命中客商档案) - int taxNoFilteredCount = 0; - // 镜像新增条数(票池有、本地无) - int created = 0; - // 镜像重写条数(票面指纹比对后有变化才计,无变化不重写) - int updated = 0; - // 两道白名单过滤后进入镜像 upsert 的张数 - int allowedCount = 0; - // 镜像票面无变化条数(既非新增也非重写,用于解释「过滤后有票却零写入」) - int mirrorUnchanged = 0; - // 登记首次绑定/改绑条数(绑定关系发生变化) - int bound = 0; - // 登记金蝶侧信息刷新条数(已绑定,仅流水号或同步状态变化) - int refreshed = 0; - // 页级批量落库失败条数(整页跳过,下轮幂等重试) - int failed = 0; - // 登记无变化跳过条数(绑定关系与金蝶信息均一致,不写库不刷流水) - int unchanged = 0; - // 实际处理的翻页数,触达最大页数保护时配合 warn 定位截断 - int pageCount = 0; - // 客商税号白名单:客商档案有效税号(购、销双方税号均须命中,我方主体税号同样维护在客商档案),集合为空不限制 - Set customerTaxNos = customerTaxNoSet(); - log.info("定时绑定金蝶全票池开始,增量窗口 {} ~ {},发票种类:{},客商税号白名单 {} 个,单页 {} 条最多 {} 页", - windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER), - Func.isEmpty(invoiceTypes) ? "不限制" : invoiceTypes, - customerTaxNos.isEmpty() ? "无(不限制)" : String.valueOf(customerTaxNos.size()), - POOL_PAGE_SIZE, MAX_POOL_PAGES); - int pageNo = 1; - for (; pageNo <= MAX_POOL_PAGES; pageNo++) { - List rows = queryPoolPage(windowStart, windowEnd, pageNo); - if (rows.isEmpty()) { - break; - } - pageCount++; - pulledCount += rows.size(); - // 发票种类白名单 + 客商税号白名单(购、销双方税号均须命中)过滤(翻页终止仍按原始页条数判断) - List typeAllowedRows = filterByInvoiceTypes(rows); - int pageTypeFiltered = rows.size() - typeAllowedRows.size(); - filteredCount += pageTypeFiltered; - List allowedRows = filterByArchiveTaxNos(customerTaxNos, typeAllowedRows); - int pageTaxFiltered = typeAllowedRows.size() - allowedRows.size(); - taxNoFilteredCount += pageTaxFiltered; - allowedCount += allowedRows.size(); - // 每页过滤漏斗:定位「拉了几千张却一张都不入库」到底卡在发票种类还是客商税号白名单 - log.info("定时绑定金蝶全票池 第{}页过滤明细:原始 {} 张,发票种类白名单外 {} 张,客商税号白名单外 {} 张,通过 {} 张", - pageNo, rows.size(), pageTypeFiltered, pageTaxFiltered, allowedRows.size()); - // ② 镜像批量 upsert:票池为唯一事实源,无论是否已登记均以票池为准,票面字段有变化才重写(批量查改避免逐条 N+1) - BatchUpsertResult upsertResult = batchUpsertInvoicePool(allowedRows); - Map mirrorMap = upsertResult.mirrors(); - created += upsertResult.created(); - updated += upsertResult.updated(); - mirrorUnchanged += upsertResult.unchanged(); - // ③ 同页发票号码批量定位收票登记,内存比对变更后页级批量落库(无变化不写库不刷流水;批量失败整页跳过待下轮) - if (!mirrorMap.isEmpty()) { - Map> receiptMap = list(Wrappers.lambdaQuery() - .in(InvoiceReceipt::getInvoiceNo, mirrorMap.keySet()) - .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() - .filter(receipt -> !Objects.equals(receipt.getIsDeleted(), 1)) - .collect(Collectors.groupingBy(InvoiceReceipt::getInvoiceNo)); - // 镜像有了却没人登记:本页不产生任何绑定,属正常(票池范围大于我方登记范围) - if (receiptMap.isEmpty()) { - log.info("定时绑定金蝶全票池 第{}页 {} 张镜像未匹配到收票登记(发票号码未登记或登记已作废)", - pageNo, mirrorMap.size()); - } - List targets = new ArrayList<>(); - for (Map.Entry entry : mirrorMap.entrySet()) { - for (InvoiceReceipt receipt : receiptMap.getOrDefault(entry.getKey(), List.of())) { - KingdeeInvoicePool mirror = entry.getValue(); - // A801-1 未返回流水号时保持登记原值,与镜像非空字段更新口径一致,避免空值每轮判为变更 - String kingdeeBillNo = Func.isNotEmpty(mirror.getKingdeeBillNo()) - ? mirror.getKingdeeBillNo() : receipt.getKingdeeBillNo(); - boolean unbound = receipt.getKingdeeInvoicePoolId() == null - || !Objects.equals(receipt.getKingdeeInvoicePoolId(), mirror.getId()); - boolean statusChanged = !Objects.equals(receipt.getKingdeeStatus(), mirror.getKingdeeStatus()); - boolean billNoChanged = !Objects.equals(receipt.getKingdeeBillNo(), kingdeeBillNo); - // 变更检测:绑定关系、金蝶流水号、同步状态三项均无变化则本轮跳过,避免整窗每轮重写与流水刷屏 - if (!unbound && !statusChanged && !billNoChanged) { - unchanged++; - continue; - } - // 内存回填:绑定关系对齐当前发票号码对应镜像(同租户同发票号镜像唯一),票面字段不覆盖人工登记信息 - receipt.setKingdeeInvoicePoolId(mirror.getId()); - receipt.setKingdeeBillNo(kingdeeBillNo); - receipt.setKingdeeStatus(mirror.getKingdeeStatus()); - // 流水只在绑定关系变化或金蝶状态迁移时写,纯流水号回填不刷流水 - InvoiceReceiptRecord syncRecord = unbound || statusChanged ? buildSyncRecord(receipt, - unbound ? "定时绑定金蝶票池发票" : "定时刷新金蝶票池状态") : null; - targets.add(new BindTarget(receipt, mirror, unbound, syncRecord)); - } - } - if (!targets.isEmpty()) { - try { - // 页级事务批量落库:绑定批量更新 + 同步流水批量写入(JDBC batch,整页原子生效) - self.applyInvoicePoolSyncBatch( - targets.stream().map(BindTarget::receipt).toList(), - targets.stream().map(BindTarget::syncRecord).filter(Objects::nonNull).toList()); - int pageBound = 0; - int pageRecorded = 0; - for (BindTarget target : targets) { - if (target.syncRecord() != null) { - pageRecorded++; - } - if (target.unbound()) { - bound++; - pageBound++; - log.info("定时绑定金蝶票池发票成功,第{}页,发票号码={},登记id={},金蝶流水号={}", - pageNo, target.receipt().getInvoiceNo(), target.receipt().getId(), target.mirror().getSerialNo()); - } else { - refreshed++; - } - } - log.info("定时绑定金蝶票池发票第{}页落库完成,变更 {} 条(绑定 {} 条、状态刷新 {} 条),同步流水 {} 条", - pageNo, targets.size(), pageBound, targets.size() - pageBound, pageRecorded); - } catch (Exception batchException) { - // 批量落库失败:整页跳过计入失败,下轮任务幂等重试,不做逐单降级(保持全批量链路) - failed += targets.size(); - log.error("定时绑定金蝶票池发票批量落库异常,第{}页 {} 条跳过待下轮重试", pageNo, targets.size(), batchException); - } - } - } - if (rows.size() < POOL_PAGE_SIZE) { - break; - } + String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 笔:" + String.join(";", failures); } - if (pageNo > MAX_POOL_PAGES) { - // 末轮仍满页说明增量窗口数据量已超过翻页保护,尾部数据本轮未处理(下轮窗口未变仍会漏,须调整配置) - log.warn("金蝶全票池增量窗口翻页达最大保护 {} 页({} 条)仍未到底,窗口尾部本轮未处理,请缩小 incrementDays 或调大保护页数", - MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE); - } - // ④ 补偿绑定:仍为未绑定状态的登记按发票号码精确补查(不受增量窗口限制), - // 兜底增量窗口滑动或批量落库持续失败导致的漏绑——发票在金蝶票池可随时按号补查,直到绑定成功为止 - int compensated = compensateUnboundReceipts(self); - String summary = "金蝶全票池同步:翻页" + pageCount + "页拉取" + pulledCount + "张发票,类型过滤" + filteredCount - + "张,客商过滤" + taxNoFilteredCount + "张,过滤后剩" + allowedCount + "张,镜像新增" + created + "更新" - + updated + "无变化" + mirrorUnchanged + ",绑定" + bound + ",状态刷新" - + refreshed + ",登记无变化跳过" + unchanged + ",失败" + failed + ",补偿绑定" + compensated + ",耗时" - + (System.currentTimeMillis() - startMillis) + "ms"; - log.info("定时绑定金蝶全票池结束,{}", summary); return summary; } /** - * 补偿绑定:未绑定登记(kingdeeInvoicePoolId 为空)按发票号码补查 A801-1 并批量落库, - * 不受增量窗口限制,防漏绑兜底;单轮限量防调用量放大,未查到则跳过待下轮,白名单同样约束。 - * 镜像一次批量查询复用(含主流程本轮已落库镜像,命中免远程),缺失才按号精确补查(rim 契约无批量号码参数), - * 新增镜像批量写入、绑定页级事务批量落库 + * 单张收票登记推送编排:前置校验 → A102-4 预检 → A801-3 幂等预检 → A303-2 保存 → pushed 落库 → + * A302-6 指定应付 → assigned;保存/指定阶段失败置 failed(预检拦截不改状态),断点可凭防重键续推 * - * @param self 自代理,批量落库走事务代理 - * @return 本轮补偿绑定成功条数 + * @param plan 预检组装的推送计划 + * @param kingdeeRows A801-3 幂等预检命中的金蝶收票单(发票号码 -> 行) + * @param apBillNoBySrcCode A102-4 已生成的财务应付单(防重键 -> 单据编号) + * @param context 金蝶同步上下文(合同/客商编码缓存) + * @return 推送结果描述 */ - private int compensateUnboundReceipts(IInvoiceReceiptService self) { - List unboundReceipts = list(Wrappers.lambdaQuery() - .isNull(InvoiceReceipt::getKingdeeInvoicePoolId) - .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED) - .orderByAsc(InvoiceReceipt::getCreateTime) - .last("limit " + compensateLimit)); - if (unboundReceipts.isEmpty()) { - return 0; + private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map kingdeeRows, + Map apBillNoBySrcCode, KingdeeSyncContext context) { + InvoiceReceipt receipt = plan.receipt(); + log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(), + receipt.getKingdeePushStatus(), plan.apBillSrcCode()); + if (Func.isNotBlank(plan.blockedReason())) { + log.warn("收票登记[{}]推送金蝶收票单拦截:{}", receipt.getInvoiceNo(), plan.blockedReason()); + throw new ServiceException(plan.blockedReason()); } - long startMillis = System.currentTimeMillis(); - log.info("定时绑定金蝶全票池补偿开始,未绑定登记 {} 条(限量 {} 条)", unboundReceipts.size(), compensateLimit); - // ① 本地镜像一次批量查询(主流程本轮刚落库的镜像一并命中,免远程补查) - List invoiceNos = unboundReceipts.stream() - .map(InvoiceReceipt::getInvoiceNo).filter(Func::isNotEmpty).map(String::trim).distinct().toList(); - Map mirrorMap = invoicePoolMapper.selectList( - Wrappers.lambdaQuery() - .in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream() - .collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first)); - // ② 内存回填绑定;本地镜像缺失的按发票号码精确补查,新增镜像攒批 - List targets = new ArrayList<>(); - List newMirrors = new ArrayList<>(); - List poolMissInvoiceNos = new ArrayList<>(); - int localHitCount = mirrorMap.size(); - int remoteQueryCount = 0; - for (InvoiceReceipt receipt : unboundReceipts) { - if (Func.isEmpty(receipt.getInvoiceNo())) { - log.warn("补偿绑定跳过,登记发票号码为空,登记id={}", receipt.getId()); - continue; + String apBillSrcCode = plan.apBillSrcCode(); + if (!apBillNoBySrcCode.containsKey(apBillSrcCode)) { + log.warn("收票登记[{}]推送金蝶收票单拦截:结算单[{}]未生成财务应付单(防重键={})", receipt.getInvoiceNo(), + plan.settlement().getFormalSettlementNo(), apBillSrcCode); + throw new ServiceException("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo() + + "未生成财务应付单"); + } + String billNo; + BfiReceivedInvoiceVO kingdeeRow = kingdeeRows.get(receipt.getInvoiceNo()); + if (kingdeeRow != null) { + if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(kingdeeRow.getInvoicestatus())) { + String abnormalReason = truncatePushMsg("金蝶侧收票单发票状态异常(" + + Func.toStrWithEmpty(kingdeeRow.getInvoicestatusTitle(), kingdeeRow.getInvoicestatus()) + + "),请先在金蝶处理后再重推"); + log.warn("收票登记[{}]推送金蝶收票单拦截:{},金蝶单据编号={}", receipt.getInvoiceNo(), abnormalReason, + kingdeeRow.getBillno()); + applyReceivedInvoicePushResult(receipt, kingdeeRow.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + abnormalReason, true); + throw new ServiceException(abnormalReason); } - String invoiceNo = receipt.getInvoiceNo().trim(); - KingdeeInvoicePool mirror = mirrorMap.get(invoiceNo); - if (mirror == null) { - // rim 契约无批量号码参数,本地镜像缺失只能逐号远程补查,是本轮最主要的远程调用开销来源 - remoteQueryCount++; - List poolInvoices = filterByInvoiceTypes(queryPoolByInvoiceNo(invoiceNo)); - if (poolInvoices.isEmpty()) { - // 金蝶票池暂无该发票:跳过不写库,待后续轮次补查,避免流水刷屏 - poolMissInvoiceNos.add(invoiceNo); - continue; + billNo = kingdeeRow.getBillno(); + log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存续推指定", receipt.getInvoiceNo(), billNo); + } else { + try { + // 镜像缺明细行/票种编码(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写,镜像入池时已过白名单,回填不再复检 + if (Func.isBlank(plan.mirror().getItemsJson()) || Func.isBlank(plan.mirror().getInvoiceTypeCode())) { + log.info("收票登记[{}]镜像缺明细/票种编码,按票号回填 A801-1", receipt.getInvoiceNo()); + fetchMirrorItemsFromPool(plan.mirror()); } - mirror = fillInvoicePool(null, poolInvoices.get(0)); - newMirrors.add(mirror); - mirrorMap.put(mirror.getInvoiceNo(), mirror); + log.info("收票登记[{}]开始 A303-2 推送金蝶收票单", receipt.getInvoiceNo()); + ContractManage contract = kingdeeApBillSyncSupport.requireContract(plan.settlement().getContractId(), context); + billNo = kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, context); + log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}]", receipt.getInvoiceNo(), billNo); + } catch (RuntimeException pushException) { + log.warn("收票登记[{}]A303-2 推送失败:{}", receipt.getInvoiceNo(), pushException.getMessage()); + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true); + throw pushException; } - receipt.setKingdeeInvoicePoolId(mirror.getId()); - receipt.setKingdeeBillNo(mirror.getKingdeeBillNo()); - receipt.setKingdeeStatus(mirror.getKingdeeStatus()); - // 补查对象均为未绑定登记,绑定关系必然变化,流水必写 - targets.add(new BindTarget(receipt, mirror, true, buildSyncRecord(receipt, "补偿绑定金蝶票池发票"))); } - if (!poolMissInvoiceNos.isEmpty()) { - log.warn("补偿绑定跳过,金蝶票池未查询到或类型白名单外发票 {} 张,待后续轮次补查,发票号码={}", - poolMissInvoiceNos.size(), poolMissInvoiceNos); + // 保存成功先落单号+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付 + applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false); + try { + log.info("收票登记[{}]开始 A302-6 指定应付:应付单防重键={}", receipt.getInvoiceNo(), apBillSrcCode); + kingdeeReceivedInvoiceSyncSupport.assignApBill(receipt.getInvoiceNo(), apBillSrcCode); + } catch (RuntimeException assignException) { + log.warn("收票登记[{}]A302-6 指定应付失败:{}", receipt.getInvoiceNo(), assignException.getMessage()); + applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("金蝶收票单指定应付失败:" + assignException.getMessage()), true); + throw assignException; } - if (targets.isEmpty()) { - log.info("定时绑定金蝶全票池补偿结束,无可补偿登记,本地镜像命中 {} 条,远程按号补查 {} 次,票池无票 {} 张,耗时 {}ms", - localHitCount, remoteQueryCount, poolMissInvoiceNos.size(), System.currentTimeMillis() - startMillis); - return 0; - } - // ③ 批量落库:新增镜像批量写入 + 绑定页级事务批量落库(失败整轮跳过待下轮重试,绑定幂等) - if (!newMirrors.isEmpty()) { - Db.saveBatch(newMirrors); - log.info("补偿绑定批量写入新增镜像 {} 条", newMirrors.size()); - } - self.applyInvoicePoolSyncBatch( - targets.stream().map(BindTarget::receipt).toList(), - targets.stream().map(BindTarget::syncRecord).filter(Objects::nonNull).toList()); - targets.forEach(target -> log.info("补偿绑定金蝶票池发票成功,发票号码={},登记id={},金蝶流水号={}", - target.receipt().getInvoiceNo(), target.receipt().getId(), target.mirror().getSerialNo())); - log.info("定时绑定金蝶全票池补偿结束,补偿绑定 {} 条,本地镜像命中 {} 条,远程按号补查 {} 次(新增镜像 {} 条),票池无票 {} 张,耗时 {}ms", - targets.size(), localHitCount, remoteQueryCount, newMirrors.size(), poolMissInvoiceNos.size(), - System.currentTimeMillis() - startMillis); - return targets.size(); + applyReceivedInvoicePushResult(receipt, billNo, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true); + log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已指定应付单", receipt.getInvoiceNo(), billNo); + return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单"; } + /** + * 推送前置组装:状态机、绑定、查验、一票一应付、应付单防重键;不满足项写入 blockedReason(不拦截整批,逐单报因) + */ + private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt, + List relations, + Map mirrorById, + Map settlementById, + Map> detailsBySettlement) { + String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED); + if (!TransportBusiConstant.APPROVED.equals(receipt.getApprovalStatus())) { + return new ReceivedInvoicePushPlan(receipt, null, null, null, "仅审批通过的收票登记允许推送金蝶"); + } + if (KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) { + return new ReceivedInvoicePushPlan(receipt, null, null, null, "收票单已指定应付,无需重复推送"); + } + KingdeeInvoicePool mirror = receipt.getKingdeeInvoicePoolId() == null ? null + : mirrorById.get(receipt.getKingdeeInvoicePoolId()); + if (mirror == null) { + return new ReceivedInvoicePushPlan(receipt, null, null, null, "收票登记未绑定票池镜像,请先完成同步绑定"); + } + if ("2".equals(mirror.getCheckStatus())) { + return new ReceivedInvoicePushPlan(receipt, mirror, null, null, "金蝶发票查验不通过,请先人工核实票池发票"); + } + if (relations.size() != 1 || relations.get(0).getFormalSettlementId() == null) { + return new ReceivedInvoicePushPlan(receipt, mirror, null, null, + "金蝶不支持一张发票指定多张应付单,收票登记须恰好分摊一张应付正式结算单"); + } + FormalSettlement settlement = settlementById.get(relations.get(0).getFormalSettlementId()); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1) + || !Objects.equals(settlement.getStatus(), 1) + || !TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus()) + || !"payable".equals(settlement.getSettlementType())) { + return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, "分摊结算单不是审批通过的应付正式结算单"); + } + List details = detailsBySettlement.getOrDefault(settlement.getId(), List.of()); + if (details.isEmpty()) { + return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, + "结算单" + settlement.getFormalSettlementNo() + "无结算明细,无法组装应付单防重键"); + } + return new ReceivedInvoicePushPlan(receipt, mirror, settlement, + kingdeeApBillSyncSupport.apBillSrcCode(settlement, details), null); + } + + /** A801-3 幂等预检批量查询:未终态计划按购方单位分组,窗口内翻页匹配防重键 */ + private Map queryKingdeeReceivedInvoiceRows(Map planByReceipt) { + Map> invoiceNosByBuyer = new LinkedHashMap<>(); + for (ReceivedInvoicePushPlan plan : planByReceipt.values()) { + if (plan.blockedReason() != null) { + continue; + } + invoiceNosByBuyer.computeIfAbsent(Func.toStrWithEmpty(plan.mirror().getReceiverName(), ""), + key -> new LinkedHashSet<>()).add(plan.receipt().getInvoiceNo()); + } + Map rows = new LinkedHashMap<>(); + LocalDateTime windowEnd = LocalDateTime.now(); + LocalDateTime windowStart = windowEnd.minusDays(Math.max(receivedQueryWindowDays, 1)); + for (Map.Entry> entry : invoiceNosByBuyer.entrySet()) { + if (StringUtil.isBlank(entry.getKey())) { + continue; + } + rows.putAll(kingdeeReceivedInvoiceSyncSupport.queryReceivedInvoiceBySrcCodes(entry.getKey(), + entry.getValue(), windowStart, windowEnd)); + } + return rows; + } + + /** + * 推送/回写结果落库:回写金蝶收票单号、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水 + */ + private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String pushStatus, + String pushMsg, boolean writeRecord) { + receipt.setKingdeeReceivedInvoiceNo(receivedInvoiceNo); + receipt.setKingdeePushStatus(pushStatus); + receipt.setKingdeePushMsg(pushMsg); + receipt.setKingdeePushTime(LocalDateTime.now()); + updateById(receipt); + if (writeRecord) { + record(receipt.getId(), "sync", "推送金蝶收票单", receipt.getApprovalStatus(), receipt.getApprovalStatus(), + Func.isNotBlank(pushMsg) ? pushMsg : "金蝶收票单[" + Func.toStr(receivedInvoiceNo) + "]已生成并指定应付", + receipt.getKingdeeBillNo()); + } + } + + /** 按票号窄查 A801-1 回填镜像明细行与票种编码(仅补写既有镜像字段,不新建镜像) */ + private void fetchMirrorItemsFromPool(KingdeeInvoicePool mirror) { + List poolRows = queryPoolByInvoiceNo(mirror.getInvoiceNo()); + BfiInvoicePoolVO poolRow = poolRows.stream() + .filter(row -> Func.isNotEmpty(row.getInvoiceNo()) + && mirror.getInvoiceNo().equals(row.getInvoiceNo().trim())) + .findFirst().orElse(null); + if (poolRow == null) { + throw new ServiceException("金蝶票池未查询到发票" + mirror.getInvoiceNo() + "明细,请先按票号补查"); + } + upsertInvoicePool(mirror, poolRow); + } + + private String truncatePushMsg(String message) { + if (message == null) { + return null; + } + return message.length() > 500 ? message.substring(0, 500) : message; + } + + /** + * 收票单推送预检计划(blockedReason 非空表示前置拦截,不进入推送流程) + * + * @param receipt 收票登记 + * @param mirror 绑定的票池镜像(可为 null) + * @param settlement 唯一分摊的应付正式结算单(可为 null) + * @param apBillSrcCode 财务应付单防重键(无法组装时为 null) + * @param blockedReason 前置拦截原因 + */ + private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror, + FormalSettlement settlement, String apBillSrcCode, String blockedReason) { + } + + // ==================== A801-3 收票结果定时回写(异常回收/指定补偿/单号补登) ==================== + @Override - @Transactional(rollbackFor = Exception.class) - public void applyInvoicePoolSyncBatch(List receipts, List records) { - if (Func.isEmpty(receipts) && Func.isEmpty(records)) { - return; + public String syncKingdeeReceivedInvoiceResult() { + List pendingReceipts = list(Wrappers.lambdaQuery() + .in(InvoiceReceipt::getKingdeePushStatus, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, + KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED) + .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED) + .isNotNull(InvoiceReceipt::getKingdeeInvoicePoolId) + .orderByAsc(InvoiceReceipt::getId)); + if (pendingReceipts.isEmpty()) { + return "金蝶收票单结果回写:无待回写收票登记"; } - // 页级事务:绑定批量更新 + 同步流水批量写入,整页原子生效;失败由调用方降级逐单隔离处理 - if (Func.isNotEmpty(receipts)) { - updateBatchById(receipts); + List mirrorIds = pendingReceipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId) + .filter(Objects::nonNull).distinct().toList(); + Map mirrorById = invoicePoolMapper.selectBatchIds(mirrorIds).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first)); + // pushed(保存成功后中断、指定未完成)集合:批量预组装应付单防重键供指定补偿 + List pushedIds = pendingReceipts.stream() + .filter(receipt -> KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())) + .map(InvoiceReceipt::getId).toList(); + Map apSrcCodeByReceiptId = pushedIds.isEmpty() ? Map.of() + : resolveApBillSrcCodesForWriteBack(pushedIds); + Map> receiptsByBuyer = new LinkedHashMap<>(); + for (InvoiceReceipt receipt : pendingReceipts) { + KingdeeInvoicePool mirror = mirrorById.get(receipt.getKingdeeInvoicePoolId()); + if (mirror == null || StringUtil.isBlank(mirror.getReceiverName())) { + log.warn("金蝶收票单结果回写跳过:收票登记[{}]无镜像购方名称", receipt.getInvoiceNo()); + continue; + } + receiptsByBuyer.computeIfAbsent(mirror.getReceiverName().trim(), key -> new ArrayList<>()).add(receipt); } - if (Func.isNotEmpty(records)) { - Db.saveBatch(records); + LocalDateTime windowEnd = LocalDateTime.now(); + LocalDateTime windowStart = windowEnd.minusDays(Math.max(receivedResultWindowDays, 1)); + int recovered = 0; + int reassigned = 0; + int filled = 0; + List failures = new ArrayList<>(); + for (Map.Entry> entry : receiptsByBuyer.entrySet()) { + Set invoiceNos = entry.getValue().stream().map(InvoiceReceipt::getInvoiceNo) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map matched = kingdeeReceivedInvoiceSyncSupport + .queryReceivedInvoiceBySrcCodes(entry.getKey(), invoiceNos, windowStart, windowEnd); + for (InvoiceReceipt receipt : entry.getValue()) { + BfiReceivedInvoiceVO row = matched.get(receipt.getInvoiceNo()); + // 窗口内查不到不判为删除(金蝶修改时间延迟/滑窗),保持现状由人工重推兜底 + if (row == null) { + continue; + } + try { + if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(row.getInvoicestatus())) { + applyReceivedInvoicePushResult(receipt, Func.toStrWithEmpty(row.getBillno(), + receipt.getKingdeeReceivedInvoiceNo()), KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("金蝶侧收票单发票状态异常(" + Func.toStrWithEmpty(row.getInvoicestatusTitle(), + row.getInvoicestatus()) + "),已回收推送状态,请在金蝶删除/解除指定后重推"), true); + recovered++; + continue; + } + if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())) { + reassignPushedReceivedInvoice(receipt, row, apSrcCodeByReceiptId.get(receipt.getId())); + reassigned++; + continue; + } + // assigned 终态仅补登缺失的金蝶收票单号(保存成功但响应丢失的历史数据) + if (StringUtil.isBlank(receipt.getKingdeeReceivedInvoiceNo()) && Func.isNotBlank(row.getBillno())) { + applyReceivedInvoicePushResult(receipt, row.getBillno(), + KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, false); + filled++; + } + } catch (RuntimeException writeBackException) { + failures.add("发票" + receipt.getInvoiceNo() + ":" + writeBackException.getMessage()); + log.warn("金蝶收票单结果回写失败,收票登记id={}", receipt.getId(), writeBackException); + } + } } + String summary = "金蝶收票单结果回写完成:轮询 " + pendingReceipts.size() + " 笔,异常回收 " + recovered + + " 笔,指定补偿 " + reassigned + " 笔,单号补登 " + filled + " 笔"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 笔:" + String.join(";", failures); + } + return summary; + } + + /** pushed 单据指定补偿:金蝶收票单存在且状态正常,按发票防重键+应付防重键重发 A302-6 */ + private void reassignPushedReceivedInvoice(InvoiceReceipt receipt, BfiReceivedInvoiceVO row, String apBillSrcCode) { + if (StringUtil.isBlank(apBillSrcCode)) { + applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("收票单指定应付补偿失败:收票登记分摊关系不满足一票一应付,请人工核对"), true); + throw new ServiceException("收票单指定应付补偿失败:分摊关系不满足一票一应付"); + } + try { + kingdeeReceivedInvoiceSyncSupport.assignApBill(receipt.getInvoiceNo(), apBillSrcCode); + } catch (RuntimeException assignException) { + applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("收票单指定应付补偿失败:" + assignException.getMessage()), true); + throw assignException; + } + applyReceivedInvoicePushResult(receipt, row.getBillno(), KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true); + } + + /** 回写补偿用:批量组装 pushed 单据的财务应付单防重键(不满足一票一应付的登记不返回该键) */ + private Map resolveApBillSrcCodesForWriteBack(List receiptIds) { + Map> relationsByReceipt = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceReceiptSettlement::getInvoiceReceiptId, receiptIds)).stream() + .collect(Collectors.groupingBy(InvoiceReceiptSettlement::getInvoiceReceiptId)); + List settlementIds = relationsByReceipt.values().stream().flatMap(List::stream) + .map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + Map settlementById = settlementIds.isEmpty() ? Map.of() + : formalSettlementMapper.selectBatchIds(settlementIds).stream() + .collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first)); + List payableSettlementIds = settlementById.values().stream() + .filter(settlement -> "payable".equals(settlement.getSettlementType())) + .map(FormalSettlement::getId).toList(); + Map> detailsBySettlement = payableSettlementIds.isEmpty() ? Map.of() + : formalSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, payableSettlementIds) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)); + Map result = new LinkedHashMap<>(); + for (Long receiptId : receiptIds) { + List relations = relationsByReceipt.getOrDefault(receiptId, List.of()); + if (relations.size() != 1 || relations.get(0).getFormalSettlementId() == null) { + continue; + } + FormalSettlement settlement = settlementById.get(relations.get(0).getFormalSettlementId()); + List details = settlement == null ? List.of() + : detailsBySettlement.getOrDefault(settlement.getId(), List.of()); + if (!details.isEmpty()) { + result.put(receiptId, kingdeeApBillSyncSupport.apBillSrcCode(settlement, details)); + } + } + return result; } @Override @@ -793,7 +933,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl queryPoolPage(LocalDateTime windowStart, LocalDateTime windowEnd, 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(POOL_INVOICE_STATUS_NORMAL); - BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); - param.setQueryParam(data); - BfiPageVO page = bfiService.queryInvoicePool(param); - log.info("定时绑定金蝶全票池 第{}页拉取 {} 条", pageNo, page.getRows() == null ? 0 : page.getRows().size()); - return page.getRows() == null ? List.of() : page.getRows(); + private KingdeeInvoicePool persistMirrorFromRemote(String invoiceNo) { + if (StringUtil.isBlank(invoiceNo)) { + throw new ServiceException("请填写发票号码"); + } + String trimmedInvoiceNo = invoiceNo.trim(); + KingdeeInvoicePool existing = invoicePoolMapper.selectOne(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, trimmedInvoiceNo) + .last("FOR UPDATE")); + BfiInvoicePoolVO poolRow = queryPoolByInvoiceNo(trimmedInvoiceNo).stream() + .filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()) + && trimmedInvoiceNo.equals(row.getInvoiceNo().trim())) + .findFirst() + .orElse(null); + if (poolRow == null) { + throw new ServiceException("金蝶票池未查询到发票 " + trimmedInvoiceNo + ",不能保存"); + } + return upsertInvoicePool(existing, poolRow); } /** @@ -863,59 +1010,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl rows) { - Map mirrorMap = new LinkedHashMap<>(); - if (rows.isEmpty()) { - return new BatchUpsertResult(mirrorMap, 0, 0, 0); - } - List invoiceNos = rows.stream() - .map(row -> row.getInvoiceNo().trim()) - .distinct() - .toList(); - // 同页发票号码一次批量查询既有镜像 - Map existingMap = invoicePoolMapper.selectList( - Wrappers.lambdaQuery() - .in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream() - .collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first)); - List toInsert = new ArrayList<>(); - List toUpdate = new ArrayList<>(); - int unchangedCount = 0; - for (BfiInvoicePoolVO row : rows) { - if (Func.isEmpty(row.getInvoiceNo())) { - continue; - } - KingdeeInvoicePool existing = existingMap.get(row.getInvoiceNo().trim()); - // fillInvoicePool 原地改写实体,故先取填充前的票面指纹用于变更比对 - String[] fingerprintBefore = invoicePoolFingerprint(existing); - KingdeeInvoicePool mirror = fillInvoicePool(existing, row); - mirrorMap.put(mirror.getInvoiceNo(), mirror); - if (existing == null) { - toInsert.add(mirror); - } else if (invoicePoolFingerprintChanged(fingerprintBefore, mirror)) { - toUpdate.add(mirror); - } else { - unchangedCount++; - } - } - // 批量写库:失败时中断本轮,由任务层记录异常、下轮重试(upsert 幂等) - if (!toInsert.isEmpty()) { - Db.saveBatch(toInsert); - } - if (!toUpdate.isEmpty()) { - Db.updateBatchById(toUpdate); - } - log.info("定时绑定金蝶全票池镜像批量写入完成,新增 {} 条,更新 {} 条,无变化 {} 条", - toInsert.size(), toUpdate.size(), unchangedCount); - return new BatchUpsertResult(mirrorMap, toInsert.size(), toUpdate.size(), unchangedCount); - } - /** * A801-1 票池发票填充镜像(同租户发票号唯一,票池字段为镜像唯一事实源,不写库) * @@ -933,9 +1027,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl mirrors, int created, int updated, - int unchanged) { - } - - /** - * 镜像票面指纹:A801-1 回填字段的规范化取值快照,用于判断本轮是否需要重写镜像;null 镜像(新增)返回 null - */ - private String[] invoicePoolFingerprint(KingdeeInvoicePool mirror) { - if (mirror == null) { + private String buildItemsJson(BfiInvoicePoolVO row) { + if (Func.isEmpty(row.getItems())) { return null; } - String[] fingerprint = new String[POOL_FINGERPRINT_FIELDS.size()]; - for (int index = 0; index < fingerprint.length; index++) { - fingerprint[index] = fingerprintValue(POOL_FINGERPRINT_FIELDS.get(index).apply(mirror)); - } - return fingerprint; + return JsonUtil.toJson(row.getItems()); } /** - * 镜像票面是否变化:填充后为空视为维持库中原值(MyBatis-Plus 非空字段更新策略不写空值), - * 与填充前一致则不算变化,避免源侧空字段导致每轮判为变更而无效重写 - */ - private boolean invoicePoolFingerprintChanged(String[] fingerprintBefore, KingdeeInvoicePool mirror) { - if (fingerprintBefore == null) { - return true; - } - for (int index = 0; index < fingerprintBefore.length; index++) { - String fingerprintAfter = fingerprintValue(POOL_FINGERPRINT_FIELDS.get(index).apply(mirror)); - if (!fingerprintAfter.isEmpty() && !fingerprintAfter.equals(fingerprintBefore[index])) { - return true; - } - } - return false; - } - - /** 指纹取值规范化:空值统一为空串,金额去尾零使 13.0000 与 13 等价,其余按去首尾空白后的文本比对 */ - private String fingerprintValue(Object value) { - if (value == null) { - return ""; - } - if (value instanceof BigDecimal decimal) { - return decimal.stripTrailingZeros().toPlainString(); - } - return value.toString().trim(); - } - - /** - * 定时绑定目标:登记实体 + 对应镜像 + 是否绑定关系变化 + 同步流水 - * - * @param receipt 已回填绑定字段的收票登记 - * @param mirror 对应票据池镜像 - * @param unbound true 表示绑定关系发生变化(含首次绑定),false 表示仅金蝶侧信息刷新 - * @param syncRecord 同步流水,null 表示本轮变更无需记录流水 - */ - private record BindTarget(InvoiceReceipt receipt, KingdeeInvoicePool mirror, boolean unbound, - InvoiceReceiptRecord syncRecord) { - } - - /** - * 构建定时绑定同步流水(批量落库用,操作人以「定时任务」名义记录) - */ - private InvoiceReceiptRecord buildSyncRecord(InvoiceReceipt receipt, String reason) { - InvoiceReceiptRecord syncRecord = new InvoiceReceiptRecord(); - syncRecord.setInvoiceReceiptId(receipt.getId()); - syncRecord.setActionType("sync"); - syncRecord.setActionName("同步金蝶状态"); - syncRecord.setFromStatus(receipt.getApprovalStatus()); - syncRecord.setToStatus(receipt.getApprovalStatus()); - syncRecord.setOperatorName(currentOperator()); - syncRecord.setReason(reason); - syncRecord.setKingdeeBillNo(receipt.getKingdeeBillNo()); - return syncRecord; - } - - /** - * 票池发票同步状态映射:命中即 synced,票池侧作废删除(delete=1)置 failed(不作废登记,人工确认) + * 票池发票同步状态映射:命中即 synced */ private String poolKingdeeStatus(BfiInvoicePoolVO row) { - return "1".equals(row.getDelete()) ? KingdeeBfiConstant.KINGDEE_STATUS_FAILED - : KingdeeBfiConstant.KINGDEE_STATUS_SYNCED; - } - - /** - * 发票种类白名单过滤(thirdParty.bfi.invoiceReceipt.invoiceTypes 逗号分隔编码),白名单为空不限制 - */ - private List filterByInvoiceTypes(List rows) { - Set allowed = allowedInvoiceTypes(); - if (allowed.isEmpty()) { - return rows; - } - return rows.stream() - .filter(row -> allowed.contains(row.getInvoiceType() == null ? "" : row.getInvoiceType().trim())) - .toList(); - } - - private Set allowedInvoiceTypes() { - if (StringUtil.isBlank(invoiceTypes)) { - return Set.of(); - } - return Arrays.stream(invoiceTypes.split("[,,;;]")) - .map(String::trim) - .filter(StringUtil::isNotBlank) - .collect(Collectors.toSet()); - } - - /** - * 客商税号白名单过滤:购方税号与销方税号**均须**命中客商档案税号集合才放行(我方主体税号同样维护在客商档案中, - * 故进项票需购方=我方、销方=建档客商同时成立),集合为空不限制;白名单外发票不入镜像不绑定 - */ - private List filterByArchiveTaxNos(Set customerTaxNos, List rows) { - if (customerTaxNos.isEmpty()) { - return rows; - } - return rows.stream() - .filter(row -> taxNoArchived(customerTaxNos, row.getBuyerTaxNo()) - && taxNoArchived(customerTaxNos, row.getSalerTaxNo())) - .toList(); - } - - /** 税号是否命中客商档案集合(空税号不命中) */ - private boolean taxNoArchived(Set customerTaxNos, String taxNo) { - return Func.isNotEmpty(taxNo) && customerTaxNos.contains(taxNo.trim()); - } - - /** - * 加载客商档案税号集合(购、销方白名单来源):status=1 有效客商的统一社会信用代码(纳税人识别号) - */ - private Set customerTaxNoSet() { - return customerArchiveMapper.selectList(Wrappers.lambdaQuery() - .eq(CustomerArchive::getStatus, 1) - .isNotNull(CustomerArchive::getUnifiedCreditCode)).stream() - .map(CustomerArchive::getUnifiedCreditCode) - .filter(Func::isNotEmpty) - .map(String::trim) - .collect(Collectors.toSet()); + return KingdeeBfiConstant.KINGDEE_STATUS_SYNCED; } private KingdeeInvoicePool mirrorByInvoiceNo(String invoiceNo) { @@ -1184,12 +1154,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl length ? trimmed.substring(0, length) : trimmed; } - /** 租户标识:定时任务上下文无登录用户时以 default 加锁,锁粒度与数据隔离边界一致 */ - private String currentTenant() { - String tenantId = AuthUtil.getTenantId(); - return Func.isEmpty(tenantId) ? "default" : tenantId; - } - /** 操作人:定时任务上下文无登录用户时以「定时任务」名义记录流水 */ private String currentOperator() { String userName = AuthUtil.getUserName(); @@ -1216,9 +1180,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.thirdparty.bfi.constant.BfiConstant; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignRefDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolItemVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.InvoiceReceipt; +import org.springblade.transport.pojo.entity.KingdeeInvoicePool; +import org.springblade.transport.service.KingdeeSyncContext; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 金蝶收票单同步支撑组件(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付 + A801-3 收票单查询)。 + *

收票登记「推送金蝶」由 {@link org.springblade.transport.service.IInvoiceReceiptService} 编排前置校验 + * (审批通过、已绑定镜像、一票一应付、A102-4 应付单已生成预检、A801-3 幂等预检)后调用本组件; + * 报文组装取数:主表组织/币种/往来户复用应付支撑组件的合同派生方法,票面字段与分录取自票池镜像 + * (invoice_type_code / items_json,A801-1 落库)。防重键:收票单 awrp_src_code=发票号码(一票一登记天然唯一), + * awrp_entry_code=发票号码-明细行号;A302-6 按「收票单防重键 ↔ 财务应付单防重键」一票一应付指定。 + * 财务应付单侧(A102-2/A102-4)取数与校验复用 {@link KingdeeApBillSyncSupport}。

+ * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeReceivedInvoiceSyncSupport { + + /** A801-3 分页查询页大小 */ + private static final int RECEIVED_QUERY_PAGE_SIZE = 200; + /** A801-3/A303-2 统一时间格式 */ + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** A801-3 发票状态:作废(2)/红冲(3)/部分红冲(7)/全部红冲(8) 视为金蝶侧异常,回收推送状态 */ + private static final List ABNORMAL_INVOICE_STATUS = List.of("2", "3", "7", "8"); + + private final IBfiService bfiService; + private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport; + private final BfiProperties bfiProperties; + + // ==================== A801-3 收票单查询(幂等预检/定时回写共用) ==================== + + /** + * A801-3 按购方名称+修改时间窗口分页拉取收票单,本地按外部系统编号+来源单据号(防重键)过滤。 + * 契约无来源单据号查询参数,防重键匹配在返回行上进行;查询失败中断本轮,由任务层记录异常 + * + * @param buyerName 购方名称(接口必填,取镜像受票单位) + * @param srcCodes 待匹配的收票单防重键(发票号码)集合 + * @param windowStart 修改时间-开始(窗口不超一个月,调用方控制) + * @param windowEnd 修改时间-结束 + * @return 发票号码 -> 收票单行(同键多行取修改时间最新) + */ + public Map queryReceivedInvoiceBySrcCodes(String buyerName, Collection srcCodes, + LocalDateTime windowStart, LocalDateTime windowEnd) { + Map matched = new LinkedHashMap<>(); + if (Func.isBlank(buyerName) || Func.isEmpty(srcCodes)) { + return matched; + } + BfiReceivedInvoiceQueryDTO param = new BfiReceivedInvoiceQueryDTO(); + param.setBuyername(buyerName.trim()); + param.setModifytimeS(windowStart.format(TIME_FORMATTER)); + param.setModifytimeE(windowEnd.format(TIME_FORMATTER)); + List rows = BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(RECEIVED_QUERY_PAGE_SIZE); + return bfiService.queryReceivedInvoice(param); + }); + for (BfiReceivedInvoiceVO row : rows) { + if (!bfiProperties.getSrcSys().equals(row.getAwrpSrcSys()) || Func.isBlank(row.getAwrpSrcCode()) + || !srcCodes.contains(row.getAwrpSrcCode())) { + continue; + } + BfiReceivedInvoiceVO exist = matched.get(row.getAwrpSrcCode()); + // 同防重键多行(删除重建等)取修改时间最新一行 + if (exist == null || compareModifyTime(row, exist) >= 0) { + matched.put(row.getAwrpSrcCode(), row); + } + } + return matched; + } + + /** + * 金蝶侧发票状态是否异常(作废/红冲/部分红冲/全部红冲) + * + * @param invoiceStatus A801-3 invoicestatus + * @return true 表示须回收推送状态、提示人工 + */ + public boolean isAbnormalInvoiceStatus(String invoiceStatus) { + return Func.isNotBlank(invoiceStatus) && ABNORMAL_INVOICE_STATUS.contains(invoiceStatus.trim()); + } + + private int compareModifyTime(BfiReceivedInvoiceVO left, BfiReceivedInvoiceVO right) { + String leftTime = left.getModifytime() == null ? "" : left.getModifytime(); + String rightTime = right.getModifytime() == null ? "" : right.getModifytime(); + return leftTime.compareTo(rightTime); + } + + // ==================== A303-2 收票单批量保存 ==================== + + /** + * 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。 + * 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位; + * 分录逐票池明细行展开(items_json),物料编码为联调期临时配置值。 + * 联调口径沿用应付/应收侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码、税额金额), + * 非必填的地址/电话/开户行等 setter 注释保留,联调确认后放开 + * + * @param receipt 收票登记(审批通过、已绑定镜像) + * @param mirror 票池镜像(invoice_type_code/items_json 取数来源) + * @param contract 分摊应付结算单关联合同(组织/币种/往来户派生) + * @param context 金蝶同步上下文(缓存载体) + * @return 金蝶收票单单据编号 + */ + public String pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract, + KingdeeSyncContext context) { + if (Func.isBlank(mirror.getInvoiceTypeCode())) { + throw new ServiceException("推送金蝶收票单失败:镜像缺少票易通发票类型编码,请先重新绑定票池发票"); + } + List items = parseItems(mirror); + BfiReceivedInvoiceSaveDTO param = new BfiReceivedInvoiceSaveDTO(); + // 【必填】外部系统编号(候选键) + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 【必填】外部系统来源单据号(候选键):发票号码(一票一登记天然唯一,重推同键幂等) + param.setAwrpSrcCode(receipt.getInvoiceNo()); + // 发票号码(非必填但为收票单核心业务字段,必送) + param.setInvoiceno(receipt.getInvoiceNo()); + // 【必填】收票日期:以推送当日为收票日期 + param.setReceivedate(LocalDate.now().atStartOfDay().format(TIME_FORMATTER)); + // 【必填】开票日期:镜像票面开票日期 + param.setIssuedate(kingdeeApBillSyncSupport.bfiDateTimeText(mirror.getInvoiceDate())); + // 【必填】业务类型(PUR 采购,待金蝶确认) + param.setBiztype(KingdeeBfiConstant.BFI_RECEIVED_BIZ_TYPE); + // 【必填】发票类型.编码:票易通原始编码(26/27 等) + param.setInvoicetypef7Number(mirror.getInvoiceTypeCode()); + // 【必填】付款方式:赊购(与应付单一致,不允许金蝶自动推付款) + param.setPaymenttype(BfiConstant.PAY_MODE_CREDIT); + // 【必填】往来类型:进项收票固定供应商 + param.setAsstacttype(BfiConstant.ASSTACT_TYPE_SUPPLIER); + // 【必填】结算组织.编码:合同所属组织(与应付单同源) + param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract)); + // 【必填】结算币别:合同结算币种 + param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract)); + // 【必填】往来户.编码:合同乙方(开票供应商)客商编码 + param.setReceivablessuppNumber(kingdeeApBillSyncSupport.bfiAsstactNumber(contract, context)); + // 【必填】汇率表.编码 + param.setExratetableNumber(BfiConstant.EXRATE_TABLE_DEFAULT); + // 【必填】销方名称:镜像开票单位 + param.setAsstactname(mirror.getIssuerName()); + // 【必填】购方名称:镜像受票单位 + param.setBuyername(mirror.getReceiverName()); + // 录入价税合计(非必传,与应付/应收侧口径一致传 true) + param.setIsincludetax(true); + // 销方税号(非必传,联调确认后放开) + // param.setSellertin(mirror.getSalerTaxNo()); + // 购方税号(非必传,联调确认后放开) + // param.setBuyertin(mirror.getBuyerTaxNo()); + // 销方地址/电话/开户行/账号(非必传,被金蝶强校验时从镜像 bank_name/bank_account/issuing_bank/phone 补齐) + // param.setSelleraddr(null); + // param.setSellertel(mirror.getPhone()); + // param.setSellerbank(mirror.getBankName()); + // param.setSellracct(mirror.getBankAccount()); + // 备注(非必传) + // param.setRemark(receipt.getRemark()); + // 【必填】外部单据类型 + param.setAwrpSrcType(KingdeeBfiConstant.BFI_RECEIVED_INVOICE_SRC_TYPE); + // 【必填】明细分录:逐票池明细行展开 + param.setEntry(buildEntries(receipt.getInvoiceNo(), items)); + + BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param); + kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶收票单"); + return saveResult.getResult().get(0).getNumber(); + } + + /** + * 票池明细行 → A303-2 分录:金额/税额/数量/单价直取票面明细,税收分类编码取 goodsCode, + * 税率换算为金蝶税率编码(0.13→V13),物料编码为联调期临时配置值(发票明细无金蝶物料概念) + * + * @param invoiceNo 发票号码(分录防重键前缀) + * @param items 票池明细行 + * @return A303-2 明细分录 + */ + private List buildEntries(String invoiceNo, List items) { + List entries = new ArrayList<>(); + int index = 1; + for (BfiInvoicePoolItemVO item : items) { + BigDecimal amount = parseItemAmount(invoiceNo, item.getDetailAmount(), "金额"); + BigDecimal tax = parseItemAmount(invoiceNo, item.getTaxAmount(), "税额"); + BfiReceivedInvoiceSaveEntryDTO entry = new BfiReceivedInvoiceSaveEntryDTO(); + // 【必填】价税合计:明细行金额+税额 + entry.setEPricetaxtotal(kingdeeApBillSyncSupport.money(amount.add(tax))); + // 【必填】数量:票面数量,无数量兜底 1 + BigDecimal quantity = parseItemAmount(invoiceNo, item.getNum(), "数量"); + entry.setQuantity(quantity.compareTo(BigDecimal.ZERO) > 0 ? quantity : BigDecimal.ONE); + // 【必填】单价:票面单价(金额口径为价税合计录入,单价仅作参考) + entry.setPrice(parseItemAmount(invoiceNo, item.getUnitPrice(), "单价")); + // 【必填】物料编码.编码:联调期临时值,真实口径与金蝶对齐后替换 + entry.setMaterialNumber(bfiProperties.getReceivedInvoice().getMaterialNumber()); + // 【必填】税收分类.编码:票面明细税收分类编码 + if (Func.isBlank(item.getGoodsCode())) { + throw new ServiceException("推送金蝶收票单失败:发票" + invoiceNo + "第" + index + "行明细无税收分类编码"); + } + entry.setETaxclassNumber(item.getGoodsCode().trim()); + // 【必填】税率.编码:票面税率(小数)换算金蝶税率编码(0.13→V13) + entry.setTaxrateidNumber(taxRateCode(invoiceNo, index, item.getTaxRate())); + // 金额(不含税)/税额(非必传,票面直取) + entry.setEAmount(kingdeeApBillSyncSupport.money(amount)); + entry.setETax(kingdeeApBillSyncSupport.money(tax)); + // 【必填】外部系统来源分录标识(候选键):发票号码-行号 + entry.setAwrpEntryCode(invoiceNo + "-" + index); + entries.add(entry); + index++; + } + if (entries.isEmpty()) { + throw new ServiceException("推送金蝶收票单失败:发票" + invoiceNo + "票池明细为空,请先补查票池发票明细"); + } + return entries; + } + + /** + * 票池明细行 JSON 解析(items 为空时返回空列表,由调用方拦截提示补查) + */ + public List parseItems(KingdeeInvoicePool mirror) { + if (Func.isBlank(mirror.getItemsJson())) { + return List.of(); + } + BfiInvoicePoolItemVO[] items = JsonUtil.parse(mirror.getItemsJson(), BfiInvoicePoolItemVO[].class); + return items == null ? List.of() : Arrays.asList(items); + } + + /** 票面税率(小数文本)换算金蝶税率编码:0.13→V13、0.01→V1、0→V0 */ + private String taxRateCode(String invoiceNo, int lineNo, String taxRateText) { + if (Func.isBlank(taxRateText)) { + throw new ServiceException("推送金蝶收票单失败:发票" + invoiceNo + "第" + lineNo + "行明细税率为空"); + } + try { + BigDecimal percent = new BigDecimal(taxRateText.trim()).multiply(BigDecimal.valueOf(100)).stripTrailingZeros(); + return "V" + percent.toPlainString(); + } catch (NumberFormatException exception) { + throw new ServiceException("推送金蝶收票单失败:发票" + invoiceNo + "第" + lineNo + "行明细税率[" + taxRateText + "]无法解析"); + } + } + + private BigDecimal parseItemAmount(String invoiceNo, String value, String name) { + if (Func.isBlank(value)) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(value.trim()); + } catch (NumberFormatException exception) { + throw new ServiceException("推送金蝶收票单失败:发票" + invoiceNo + "明细" + name + "[" + value + "]无法解析"); + } + } + + // ==================== A302-6 收票指定应付 ==================== + + /** + * 收票单指定财务应付单(A302-6,一票一应付):按「收票单防重键(发票号码)↔ 财务应付单防重键 + * (正式结算单号_结算明细单据号拼接)」建立绑定;金蝶侧解除指定(unassign)不自动执行,人工处理后重推 + * + * @param invoiceSrcCode 收票单防重键(发票号码,即 A303-2 的 awrp_src_code) + * @param apBillSrcCode 财务应付单防重键 + */ + public void assignApBill(String invoiceSrcCode, String apBillSrcCode) { + BfiInvoiceAssignItemDTO item = new BfiInvoiceAssignItemDTO(); + item.setApInvoice(List.of(buildAssignRef(invoiceSrcCode))); + item.setApFinapbill(List.of(buildAssignRef(apBillSrcCode))); + BfiBillSaveResultVO assignResult = bfiService.invoiceAssign("assign", List.of(item)); + kingdeeApBillSyncSupport.requireBfiSaveSuccess(assignResult, "金蝶收票单指定应付"); + } + + private BfiInvoiceAssignRefDTO buildAssignRef(String awrpSrcCode) { + BfiInvoiceAssignRefDTO ref = new BfiInvoiceAssignRefDTO(); + ref.setAwrpSrcCode(awrpSrcCode); + ref.setAwrpSrcSys(bfiProperties.getSrcSys()); + return ref; + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java deleted file mode 100644 index 382be504..00000000 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java +++ /dev/null @@ -1,70 +0,0 @@ -/** - * BladeX Commercial License Agreement - * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. - *

- * Use of this software is governed by the Commercial License Agreement - * obtained after purchasing a license from BladeX. - *

- * 1. This software is for development use only under a valid license - * from BladeX. - *

- * 2. Redistribution of this software's source code to any third party - * without a commercial license is strictly prohibited. - *

- * 3. Licensees may copyright their own code but cannot use segments - * from this software for such purposes. Copyright of this software - * remains with BladeX. - *

- * Using this software signifies agreement to this License, and the software - * must not be used for illegal purposes. - *

- * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is - * not liable for any claims arising from secondary or illegal development. - *

- * Author: Chill Zhuang (bladejava@qq.com) - */ -package org.springblade.transport.task; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springblade.transport.service.IInvoiceReceiptService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 收票管理金蝶全票池发票定时绑定任务 - *

- * 定时查询金蝶发票云全票池发票数据(A801-1,经 BFI 网关 rim 契约透传), - * 按发票号码 upsert 票据池镜像并与收票登记绑定关联,回填金蝶流水号与同步状态。 - * 业务逻辑收敛在 {@link IInvoiceReceiptService#syncKingdeeInvoicePool()},便于手动触发联调。 - *

- * 调度配置(Nacos thirdParty.bfi.invoiceReceipt.*):syncEnabled 开关、cron 表达式、incrementDays 增量窗口。 - * - * @author Chill - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class InvoiceReceiptKingdeeSyncTask { - - private final IInvoiceReceiptService invoiceReceiptService; - - /** 全票池绑定任务开关,默认关闭(联调稳定后开启) */ - @Value("${thirdParty.bfi.invoiceReceipt.syncEnabled:false}") - private boolean syncEnabled; - - /** 金蝶全票池发票绑定调度,默认每30分钟执行一次 */ - @Scheduled(cron = "${thirdParty.bfi.invoiceReceipt.cron:0 */30 * * * ?}") - public void syncKingdeeInvoicePool() { - if (!syncEnabled) { - return; - } - try { - String summary = invoiceReceiptService.syncKingdeeInvoicePool(); - log.info("定时绑定金蝶全票池发票:{}", summary); - } catch (Exception exception) { - log.error("定时绑定金蝶全票池发票异常", exception); - } - } -} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java index 669c18b2..5c7dec13 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java @@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IInvoiceReceiptService; import org.springblade.transport.service.IPaymentApplicationService; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; @@ -37,6 +38,9 @@ import org.springframework.stereotype.Component; * ③ A303-5 开票结果回写:开票申请发票结果落库并推进开票完成。 * 各子流程业务逻辑收敛在对应服务方法,便于手动触发联调。 *

+ * 另有金蝶收票单结果回写(A801-3,图05 环节②③异常回收/指定补偿)独立调度, + * 配置 thirdParty.bfi.receivedInvoiceResult.*。 + *

* 调度配置(Nacos thirdParty.bfi.payResult.*):syncEnabled 开关、cron 表达式。 * * @author Chill @@ -49,6 +53,7 @@ public class PaymentKingdeeResultTask { private final IPaymentApplicationService paymentApplicationService; private final IFormalSettlementService formalSettlementService; private final IInvoiceApplicationService invoiceApplicationService; + private final IInvoiceReceiptService invoiceReceiptService; /** 付款结果回写开关,默认开启 */ @Value("${thirdParty.bfi.payResult.syncEnabled:false}") @@ -82,4 +87,22 @@ public class PaymentKingdeeResultTask { log.error("定时回写金蝶开票结果异常", exception); } } + + /** 金蝶收票单结果回写开关(A801-3),默认关闭(联调稳定后开启) */ + @Value("${thirdParty.bfi.receivedInvoiceResult.syncEnabled:false}") + private boolean receivedInvoiceResultEnabled; + + /** 金蝶收票单结果回写调度(异常回收/指定补偿/单号补登),默认每10分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.receivedInvoiceResult.cron:0 */10 * * * ?}") + public void syncKingdeeReceivedInvoiceResult() { + if (!receivedInvoiceResultEnabled) { + return; + } + try { + String summary = invoiceReceiptService.syncKingdeeReceivedInvoiceResult(); + log.info("定时回写金蝶收票单结果:{}", summary); + } catch (Exception exception) { + log.error("定时回写金蝶收票单结果异常", exception); + } + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java index e1039f0b..89f391e3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java @@ -62,6 +62,12 @@ public class InvoiceReceiptWrapper extends BaseEntityWrapper "同步失败"; default -> "未同步"; }); + vo.setKingdeePushStatusName(switch (entity.getKingdeePushStatus() == null ? "" : entity.getKingdeePushStatus()) { + case "pushed" -> "收票单已保存"; + case "assigned" -> "已指定应付"; + case "failed" -> "推送失败"; + default -> "未推送"; + }); return vo; } } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/SyncKingdeeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/SyncKingdeeTest.java deleted file mode 100644 index 1a14bd46..00000000 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/SyncKingdeeTest.java +++ /dev/null @@ -1,57 +0,0 @@ -/** - * BladeX Commercial License Agreement - * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. - *

- * Use of this software is governed by the Commercial License Agreement - * obtained after purchasing a license from BladeX. - *

- * 1. This software is for development use only under a valid license - * from BladeX. - *

- * 2. Redistribution of this software's source code to any third party - * without a commercial license is strictly prohibited. - *

- * 3. Licensees may copyright their own code but cannot use segments - * from this software for such purposes. Copyright of this software - * remains with BladeX. - *

- * Using this software signifies agreement to this License, and the software - * must not be used for illegal purposes. - *

- * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is - * not liable for any claims arising from secondary or illegal development. - *

- * Author: Chill Zhuang (bladejava@qq.com) - */ -package org.springblade.transport; - -import org.junit.jupiter.api.Test; -import org.springblade.transport.service.IInvoiceReceiptService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -/** - * 金蝶数据定时同步测试:手动触发收票管理金蝶全票池发票绑定(A801-1),打印执行摘要便于联调抽查。 - *

- * 测试用属性显式关闭 {@code thirdParty.bfi.invoiceReceipt.syncEnabled}:{@code @SpringBootTest} 会连同 - * {@code @EnableScheduling} 一起启动上下文,不关闭则 cron 会在同一 JVM 内并发再跑一轮, - * 与手动这一轮互相抢数据(未开启 blade.lock.enabled 时没有 single-flight 保护),执行摘要会对不上。 - * - * @author Chill - */ -@SpringBootTest(classes = TransportApplication.class, properties = { - "spring.profiles.active=dev", - "spring.main.allow-bean-definition-overriding=true", - "thirdParty.bfi.invoiceReceipt.syncEnabled=false" -}) -class SyncKingdeeTest { - - @Autowired - private IInvoiceReceiptService invoiceReceiptService; - - @Test - void syncKingdeeInvoicePool() { - String summary = invoiceReceiptService.syncKingdeeInvoicePool(); - System.out.println("金蝶数据定时同步执行结果:" + summary); - } -} diff --git a/doc/nacos/blade-dev.yaml b/doc/nacos/blade-dev.yaml index 7de03d27..c165102f 100644 --- a/doc/nacos/blade-dev.yaml +++ b/doc/nacos/blade-dev.yaml @@ -148,18 +148,6 @@ thirdParty: syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} - # 收票管理金蝶全票池发票定时绑定(A801-1 查询全票池发票数据) - invoiceReceipt: - # 绑定任务开关,默认关闭(联调稳定后开启) - syncEnabled: ${BFI_INVOICE_RECEIPT_SYNC_ENABLED:false} - # 调度 cron,默认每30分钟 - cron: ${BFI_INVOICE_RECEIPT_CRON:0 */30 * * * ?} - # 增量窗口天数(按票池更新时间),首轮/兜底全量比对窗口 - incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} - # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 - invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:} - # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) - compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} #百度OCR配置,API Key和Secret Key请通过环境变量注入 baidu: diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index db12adcd..b039f9d3 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -121,18 +121,6 @@ thirdParty: syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} - # 收票管理金蝶全票池发票定时绑定(A801-1 查询全票池发票数据) - invoiceReceipt: - # 绑定任务开关,默认关闭(联调稳定后开启) - syncEnabled: ${BFI_INVOICE_RECEIPT_SYNC_ENABLED:false} - # 调度 cron,默认每30分钟 - cron: ${BFI_INVOICE_RECEIPT_CRON:0 */30 * * * ?} - # 增量窗口天数(按票池更新时间),首轮/兜底全量比对窗口 - incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} - # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 - invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:} - # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) - compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} #百度OCR配置,API Key和Secret Key请通过环境变量注入 baidu: diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index 99c69e46..5b8e97f8 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -49,15 +49,3 @@ thirdParty: syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} # 调度 cron,默认每10分钟 cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} - # 收票管理金蝶全票池发票定时绑定(A801-1 查询全票池发票数据) - invoiceReceipt: - # 绑定任务开关,默认关闭(联调稳定后开启) - syncEnabled: ${BFI_INVOICE_RECEIPT_SYNC_ENABLED:false} - # 调度 cron,默认每30分钟 - cron: ${BFI_INVOICE_RECEIPT_CRON:0 */30 * * * ?} - # 增量窗口天数(按票池更新时间),首轮/兜底全量比对窗口 - incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} - # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 - invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:} - # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) - compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} diff --git a/doc/sql/transport/blade_invoice_receipt_kingdee_push_20260922.sql b/doc/sql/transport/blade_invoice_receipt_kingdee_push_20260922.sql new file mode 100644 index 00000000..fee3d8f5 --- /dev/null +++ b/doc/sql/transport/blade_invoice_receipt_kingdee_push_20260922.sql @@ -0,0 +1,94 @@ +-- 收票管理 图05 环节②③:推送金蝶收票单(A303-2 收票单批量保存 + A302-6 收票指定应付 + A801-3 定时回写) +-- 票据池镜像表 blade_kingdee_invoice_pool 新增: +-- invoice_type_code 票易通原始发票类型编码(A801-1 invoiceType,如 26/27,A303-2 invoicetypef7_number 取数) +-- items_json A801-1 发票明细行JSON(品名/数量/单价/金额/税率/税额/税收分类编码,A303-2 分录取数来源) +-- 收票登记表 blade_invoice_receipt 新增: +-- kingdee_received_invoice_no 金蝶收票单单据编号(A303-2 返回/幂等预检命中回填) +-- kingdee_push_status 收票单推送状态(unsynced 未推送 / pushed 已保存 / assigned 已指定应付 / failed 推送失败) +-- kingdee_push_msg 推送失败原因(金蝶侧删除/红冲/查验不通过等异常回收提示人工) +-- kingdee_push_time 最近一次推送/回写时间 +-- 以下结构升级支持重复执行 + +-- ① blade_kingdee_invoice_pool.invoice_type_code +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `invoice_type_code` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''票易通原始发票类型编码(A801-1 invoiceType,如26/27)'' AFTER `invoice_type`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'invoice_type_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_kingdee_invoice_pool.items_json +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `items_json` longtext COLLATE utf8mb4_general_ci COMMENT ''A801-1发票明细行JSON(A303-2分录取数来源)'' AFTER `attachments_json`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'items_json' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单单据编号(A303-2生成)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_receipt.kingdee_push_status +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_status` varchar(16) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''unsynced'' COMMENT ''收票单推送状态(unsynced未推送/pushed已保存/assigned已指定应付/failed推送失败)'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑤ blade_invoice_receipt.kingdee_push_msg +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_msg` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收票单推送失败原因'' AFTER `kingdee_push_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_msg' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑥ blade_invoice_receipt.kingdee_push_time +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_time` datetime DEFAULT NULL COMMENT ''最近一次收票单推送/回写时间'' AFTER `kingdee_push_msg`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql b/doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql new file mode 100644 index 00000000..15a4c923 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql @@ -0,0 +1,17 @@ +-- 金蝶票池镜像降级为登记存根(2026-09-22 票池改回实时查询收尾) +-- 背景:选票链路已改为实时查询 A801-1,blade_kingdee_invoice_pool 仅保留被收票登记引用的镜像行(登记存根), +-- 历史全量同步落库的未被引用镜像行属于退役数据,予以清理。 +-- 幂等:可重复执行,第二次影响 0 行。 +-- 注意:执行前必须先向用户报盘点数字并获确认(dev 库清理口径:事务 + 回滚 SQL + mysqldump 文本兜底)。 + +-- 执行前先跑盘点(只读): +-- SELECT COUNT(*) AS prune_rows FROM blade_kingdee_invoice_pool p +-- LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +-- WHERE r.id IS NULL; +-- SELECT COUNT(*) AS keep_rows FROM blade_kingdee_invoice_pool p +-- INNER JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0; + +-- 票池镜像降级为登记存根:删除未被任何未删收票登记引用的镜像行(实时查询模式上线前执行) +DELETE p FROM blade_kingdee_invoice_pool p +LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +WHERE r.id IS NULL;