✨ 收票单推送金蝶全链路(A303-2保存+A302-6指定应付+A801-3回写)与金蝶票池实时游标分页重构,下线镜像分页与定时绑定任务

This commit is contained in:
刘泉佳
2026-09-23 10:15:36 +08:00
parent 4de9534eba
commit 562127232b
25 changed files with 1187 additions and 962 deletions
@@ -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;
@@ -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;
}
@@ -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
*/
@@ -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,镜像排序字段)
*/
@@ -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<InvoiceReceiptSettlement> settlements;
@@ -0,0 +1,62 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<KingdeeInvoicePoolVO> records;
/** 当前页码,从 1 开始 */
@Schema(description = "当前页码")
private Long current;
/** 每页条数 */
@Schema(description = "每页条数")
private Long size;
/** 是否还有下一页(服务端多取 1 条探测) */
@Schema(description = "是否还有下一页")
private Boolean hasMore;
}
@@ -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;
}
@@ -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";
}
}
@@ -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";
@@ -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<IPage<KingdeeInvoicePoolVO>> invoicePool(KingdeeInvoicePoolQueryDTO query) {
@Operation(summary = "金蝶票池实时分页查询(认领查询)")
public R<KingdeeInvoicePoolPageVO> invoicePool(KingdeeInvoicePoolQueryDTO query) {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
@PostMapping("/invoice-pool/fetch")
@ApiOperationSupport(order = 22)
@Operation(summary = "按发票号码从金蝶票池补查单张发票")
public R<KingdeeInvoicePoolVO> fetchInvoicePool(@RequestParam String invoiceNo) {
return R.data(invoiceReceiptService.fetchKingdeeInvoiceByNo(invoiceNo));
}
}
@@ -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<IPage<KingdeeInvoicePoolVO>> invoicePool(KingdeeInvoicePoolQueryDTO query) {
@Operation(summary = "金蝶票池实时分页查询(含认领/登记标记)")
public R<KingdeeInvoicePoolPageVO> invoicePool(KingdeeInvoicePoolQueryDTO query) {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
@PostMapping("/invoice-pool/fetch")
@ApiOperationSupport(order = 14)
@Operation(summary = "按发票号码从金蝶票池补查单张发票")
public R<KingdeeInvoicePoolVO> 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<String> syncInvoicePool() {
return R.data(invoiceReceiptService.syncKingdeeInvoicePool());
@PostMapping("/push-kingdee")
@ApiOperationSupport(order = 15)
@Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存 + A302-6 收票指定应付)")
public R<String> pushKingdee(@RequestBody List<Long> ids) {
return R.data(invoiceReceiptService.pushKingdeeReceivedInvoice(ids));
}
@PostMapping("/sync-kingdee-result")
@ApiOperationSupport(order = 16)
@Operation(summary = "手动触发金蝶收票单结果回写(A801-3 异常回收/指定补偿)")
public R<String> syncKingdeeResult() {
return R.data(invoiceReceiptService.syncKingdeeReceivedInvoiceResult());
}
}
@@ -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<KingdeeInvoicePool> {
/**
* 金蝶票据池镜像统一分页查询:认领/登记标记由 EXISTS 子查询直出
*
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @return 统一视图分页
*/
IPage<KingdeeInvoicePoolVO> selectInvoicePoolPage(IPage<KingdeeInvoicePoolVO> 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);
}
@@ -1,87 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.transport.mapper.KingdeeInvoicePoolMapper">
<!-- 已认领:任一有效正式结算单发票行出现过(结算单作废不删发票行,须关联主单剔除作废与已删;自定义 SQL 无租户插件,手工带租户与逻辑删除) -->
<sql id="claimedExists">
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'))
</sql>
<!-- 已登记:存在非作废收票登记 -->
<sql id="registeredExists">
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)
</sql>
<!-- 统一视图列:价税合计与可认领/默认认领金额同源;镜像 invoice_type 落库即中文名 -->
<sql id="invoicePoolVoColumns">
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,
<include refid="claimedExists"/> AS claimed,
<include refid="registeredExists"/> AS registered
</sql>
<select id="selectInvoicePoolPage" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
SELECT
<include refid="invoicePoolVoColumns"/>
FROM blade_kingdee_invoice_pool p
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId}
<if test="query.invoiceNo != null and query.invoiceNo != ''">
AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%')
</if>
<if test="query.salerName != null and query.salerName != ''">
AND p.issuer_name LIKE CONCAT('%', #{query.salerName}, '%')
</if>
<if test="query.buyerName != null and query.buyerName != ''">
AND p.receiver_name LIKE CONCAT('%', #{query.buyerName}, '%')
</if>
<if test="query.invoiceType != null and query.invoiceType != ''">
AND p.invoice_type LIKE CONCAT('%', #{query.invoiceType}, '%')
</if>
<if test="query.salerTaxNo != null and query.salerTaxNo != ''">
AND p.saler_tax_no = #{query.salerTaxNo}
</if>
<if test="query.buyerTaxNo != null and query.buyerTaxNo != ''">
AND p.buyer_tax_no = #{query.buyerTaxNo}
</if>
<if test="query.invoiceDateStart != null and query.invoiceDateStart != ''">
AND p.invoice_date &gt;= #{query.invoiceDateStart}
</if>
<if test="query.invoiceDateEnd != null and query.invoiceDateEnd != ''">
AND p.invoice_date &lt;= #{query.invoiceDateEnd}
</if>
<if test="query.claimed != null and query.claimed">
AND <include refid="claimedExists"/>
</if>
<if test="query.claimed != null and !query.claimed">
AND NOT <include refid="claimedExists"/>
</if>
<if test="query.registered != null and query.registered">
AND <include refid="registeredExists"/>
</if>
<if test="query.registered != null and !query.registered">
AND NOT <include refid="registeredExists"/>
</if>
ORDER BY p.source_updated_time DESC, p.create_time DESC
</select>
<select id="selectInvoicePoolVO" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
SELECT
<include refid="invoicePoolVoColumns"/>
FROM blade_kingdee_invoice_pool p
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId} AND p.invoice_no = #{invoiceNo}
LIMIT 1
</select>
</mapper>
@@ -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<InvoiceReceipt> {
InvoiceReceiptVO detail(Long id);
/**
* 金蝶票据池镜像分页查询(含本地认领/登记标记),收票登记选票与正式结算单认领共用
* 金蝶票池实时统一查询(A801-1 游标分页 + 本地认领/登记装饰标记),收票登记选票与正式结算单认领共用
* <p>A801-1 不返回总数,改用「多取 1 条探测」得出 hasMore 驱动上一页/下一页;
* claimed/registered 仅本地展示装饰,不下推金蝶过滤。
*
* @param query 统一查询条件
* @return 镜像分页
* @return 实时游标分页
*/
IPage<KingdeeInvoicePoolVO> invoicePoolPage(KingdeeInvoicePoolQueryDTO query);
/**
* 按发票号码从金蝶票池精确补查单张发票:与定时同步同一套白名单校验,
* 通过后 upsert 入镜像并返回;不受增量时间窗口约束
*
* @param invoiceNo 发票号码
* @return 镜像统一视图
*/
KingdeeInvoicePoolVO fetchKingdeeInvoiceByNo(String invoiceNo);
KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query);
List<Map<String, Object>> settlementCandidates(String keyword, Long receiptId);
@@ -86,22 +78,23 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
String syncKingdee(Long id);
/**
* 定时查询金蝶全票池发票(A801-1)并绑定收票登记
* <p>
* 按租户加分布式锁做 single-flight(需开启 {@code blade.lock.enabled}):多副本并发触发、
* 或手动触发撞上进行中的轮次时,抢不到锁的一轮直接跳过并返回提示,不重复拉取与写库。
* 批量推送金蝶收票单(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付)。
* <p>逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、恰好分摊一张应付结算单)→
* A102-4 预检财务应付单已生成 → A801-3 幂等预检(命中跳过保存续推指定)→ A303-2 保存 →
* 立即 A302-6 指定应付;成功置 assigned 并锁单,失败置 failed 提示人工处理后重推。
*
* @return 执行摘要,含拉取页数、镜像新增/更新、绑定与状态刷新统计
* @param ids 收票登记id列表
* @return 执行摘要(逐单失败原因)
*/
String syncKingdeeInvoicePool();
String pushKingdeeReceivedInvoice(List<Long> ids);
/**
* 批量应用金蝶票池绑定结果(页级事务):登记绑定批量更新 + 同步流水批量写入,整页原子生效
* 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记);
* pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号
*
* @param receipts 变更比对后需更新、且已回填绑定字段的收票登记
* @param records 同步流水,绑定关系变化或金蝶状态迁移时才写,可为 receipts 的子集
* @return 执行摘要
*/
void applyInvoicePoolSyncBatch(List<InvoiceReceipt> receipts, List<InvoiceReceiptRecord> records);
String syncKingdeeReceivedInvoiceResult();
/**
* 应用金蝶票池同步结果:回填收票登记绑定关系与同步状态(独立事务,仅手动单条同步使用)
@@ -109,7 +102,7 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
* @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,
@@ -0,0 +1,325 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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 收票单查询)。
* <p>收票登记「推送金蝶」由 {@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}。</p>
*
* @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<String> 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 发票号码 -&gt; 收票单行(同键多行取修改时间最新)
*/
public Map<String, BfiReceivedInvoiceVO> queryReceivedInvoiceBySrcCodes(String buyerName, Collection<String> srcCodes,
LocalDateTime windowStart, LocalDateTime windowEnd) {
Map<String, BfiReceivedInvoiceVO> 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<BfiReceivedInvoiceVO> 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<BfiInvoicePoolItemVO> 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<BfiReceivedInvoiceSaveEntryDTO> buildEntries(String invoiceNo, List<BfiInvoicePoolItemVO> items) {
List<BfiReceivedInvoiceSaveEntryDTO> 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<BfiInvoicePoolItemVO> 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;
}
}
@@ -1,70 +0,0 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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;
/**
* 收票管理金蝶全票池发票定时绑定任务
* <p>
* 定时查询金蝶发票云全票池发票数据(A801-1,经 BFI 网关 rim 契约透传),
* 按发票号码 upsert 票据池镜像并与收票登记绑定关联,回填金蝶流水号与同步状态。
* 业务逻辑收敛在 {@link IInvoiceReceiptService#syncKingdeeInvoicePool()},便于手动触发联调。
* <p>
* 调度配置(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);
}
}
}
@@ -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 开票结果回写:开票申请发票结果落库并推进开票完成。
* 各子流程业务逻辑收敛在对应服务方法,便于手动触发联调。
* <p>
* 另有金蝶收票单结果回写(A801-3,图05 环节②③异常回收/指定补偿)独立调度,
* 配置 thirdParty.bfi.receivedInvoiceResult.*。
* <p>
* 调度配置(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);
}
}
}
@@ -62,6 +62,12 @@ public class InvoiceReceiptWrapper extends BaseEntityWrapper<InvoiceReceipt, Inv
case "failed" -> "同步失败";
default -> "未同步";
});
vo.setKingdeePushStatusName(switch (entity.getKingdeePushStatus() == null ? "" : entity.getKingdeePushStatus()) {
case "pushed" -> "收票单已保存";
case "assigned" -> "已指定应付";
case "failed" -> "推送失败";
default -> "未推送";
});
return vo;
}
}
@@ -1,57 +0,0 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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),打印执行摘要便于联调抽查。
* <p>
* 测试用属性显式关闭 {@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);
}
}
-12
View File
@@ -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:
-12
View File
@@ -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:
-12
View File
@@ -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}
@@ -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;
@@ -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;