From 39ffac15e270514b08a11e848e4df524479adfd3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com>
Date: Mon, 21 Sep 2026 19:14:20 +0800
Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E5=BC=80=E7=A5=A8=E7=94=B3?=
=?UTF-8?q?=E8=AF=B7=E5=90=8C=E6=AD=A5=E9=87=91=E8=9D=B6A303-1=E5=B9=B6?=
=?UTF-8?q?=E5=9B=9E=E5=86=99A303-5=E5=BC=80=E7=A5=A8=E7=BB=93=E6=9E=9C?=
=?UTF-8?q?=EF=BC=88=E9=94=80=E6=96=B9=E4=B8=8E=E7=A8=8E=E6=94=B6=E5=88=86?=
=?UTF-8?q?=E7=B1=BB=E7=BC=96=E7=A0=81=E9=9A=8F=E5=8D=95=E6=8D=AE=E5=8F=96?=
=?UTF-8?q?=E6=95=B0=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=8F=91=E7=A5=A8=E7=BB=93?=
=?UTF-8?q?=E6=9E=9C=E8=A1=A8=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../dto/InvoiceApplicationSaveRequest.java | 1 +
.../entity/InvoiceApplicationInvoice.java | 67 +++
.../pojo/entity/InvoiceApplicationLine.java | 1 +
.../pojo/vo/InvoiceApplicationVO.java | 2 +
.../InvoiceApplicationController.java | 7 +
.../InvoiceApplicationInvoiceMapper.java | 32 ++
.../service/IInvoiceApplicationService.java | 1 +
.../impl/InvoiceApplicationServiceImpl.java | 390 +++++++++++++++++-
.../wrapper/InvoiceApplicationWrapper.java | 1 +
...e_invoice_application_invoice_20260920.sql | 22 +
...ice_application_line_tax_code_20260921.sql | 21 +
11 files changed, 541 insertions(+), 4 deletions(-)
create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java
create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationInvoiceMapper.java
create mode 100644 doc/sql/transport/blade_invoice_application_invoice_20260920.sql
create mode 100644 doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java
index 207879ca..bd0130f7 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java
@@ -71,6 +71,7 @@ public class InvoiceApplicationSaveRequest implements Serializable {
@Serial private static final long serialVersionUID = 1L;
private String goodsCategory;
private String goodsName;
+ private String taxClassificationCode;
private String unit;
private BigDecimal quantity;
private BigDecimal unitPriceNoTax;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java
new file mode 100644
index 00000000..fa875a4f
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java
@@ -0,0 +1,67 @@
+/**
+ * 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.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springblade.core.tenant.mp.TenantEntity;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 开票申请金蝶发票结果实体类
+ *
+ * 定时任务经 A303-5 开票申请发票查询回写的发票明细(发票号唯一键幂等落库),
+ * 原文 JSON 整体留档便于联调与追溯
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("blade_invoice_application_invoice")
+@Schema(description = "开票申请金蝶发票结果")
+public class InvoiceApplicationInvoice extends TenantEntity {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "开票申请ID")
+ private Long invoiceApplicationId;
+
+ @Schema(description = "金蝶开票申请单编号(A303-5 serialNo)")
+ private String serialNo;
+
+ @Schema(description = "发票号码")
+ private String invoiceNo;
+
+ @Schema(description = "开票日期")
+ private LocalDateTime invoiceDate;
+
+ @Schema(description = "价税合计")
+ private BigDecimal invoiceAmount;
+
+ @Schema(description = "A303-5 返回原文JSON留档")
+ private String rawJson;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java
index 30ded9f6..1a63d8d9 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java
@@ -48,6 +48,7 @@ public class InvoiceApplicationLine extends TenantEntity {
private Integer lineNo;
private String goodsCategory;
private String goodsName;
+ private String taxClassificationCode;
private String unit;
private BigDecimal quantity;
private BigDecimal unitPriceNoTax;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java
index 664d02b0..2d497480 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java
@@ -30,6 +30,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springblade.transport.pojo.entity.InvoiceApplication;
import org.springblade.transport.pojo.entity.InvoiceApplicationDetail;
+import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
import org.springblade.transport.pojo.entity.InvoiceApplicationRecord;
import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement;
@@ -53,4 +54,5 @@ public class InvoiceApplicationVO extends InvoiceApplication {
@TableField(exist = false) private List sheets;
@TableField(exist = false) private List details;
@TableField(exist = false) private List records;
+ @TableField(exist = false) private List invoices;
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java
index c3ac9cc3..f7287ead 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java
@@ -156,4 +156,11 @@ public class InvoiceApplicationController extends BladeController {
public R syncKingdee(@RequestParam Long id) {
return R.data(invoiceApplicationService.syncKingdee(id));
}
+
+ @PostMapping("/sync-kingdee-invoice-result")
+ @ApiOperationSupport(order = 13)
+ @Operation(summary = "手动回写金蝶开票结果(A303-5)")
+ public R syncKingdeeInvoiceResult() {
+ return R.data(invoiceApplicationService.syncKingdeeInvoiceResult());
+ }
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationInvoiceMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationInvoiceMapper.java
new file mode 100644
index 00000000..b418deef
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/InvoiceApplicationInvoiceMapper.java
@@ -0,0 +1,32 @@
+/**
+ * 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.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
+
+/**
+ * 开票申请金蝶发票结果 Mapper
+ *
+ * @author Chill
+ */
+public interface InvoiceApplicationInvoiceMapper extends BaseMapper {
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java
index 93fe9fa5..b126e786 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java
@@ -55,4 +55,5 @@ public interface IInvoiceApplicationService extends BaseService
implements IInvoiceApplicationService {
@@ -105,6 +125,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl selectPage(IPage page, InvoiceApplicationVO query) {
@@ -151,6 +178,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery()
.eq(InvoiceApplicationRecord::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationRecord::getCreateTime)));
+ vo.setInvoices(invoiceApplicationInvoiceMapper.selectList(Wrappers.lambdaQuery()
+ .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id)
+ .orderByAsc(InvoiceApplicationInvoice::getCreateTime)));
return vo;
}
@@ -441,16 +471,368 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl relations = settlementRelationMapper.selectList(
+ Wrappers.lambdaQuery()
+ .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id));
+ if (Func.isEmpty(relations)) throw new ServiceException("推送金蝶开票申请失败:开票申请未关联正式结算单");
+ List arSrcCodes = new ArrayList<>();
+ String firstArBillNo = null;
+ Map settlementNoBySrcCode = new LinkedHashMap<>();
+ for (InvoiceApplicationSettlement relation : relations) {
+ FormalSettlement settlement = formalSettlementMapper.selectById(relation.getFormalSettlementId());
+ if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) {
+ throw new ServiceException("推送金蝶开票申请失败:正式结算单[" + Func.toStr(relation.getFormalSettlementNo()) + "]不存在");
+ }
+ List details = formalSettlementDetailMapper.selectList(
+ Wrappers.lambdaQuery()
+ .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId())
+ .eq(FormalSettlementDetail::getIsDeleted, 0)
+ .orderByAsc(FormalSettlementDetail::getLineNo));
+ String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details);
+ arSrcCodes.add(srcCode);
+ settlementNoBySrcCode.put(srcCode, Func.toStr(settlement.getFormalSettlementNo()));
+ if (firstArBillNo == null) {
+ firstArBillNo = settlement.getKingdeeBillNo();
+ }
+ }
+ Map existArNos = kingdeeArBillSyncSupport.queryArBillExistNos(arSrcCodes);
+ for (Map.Entry entry : settlementNoBySrcCode.entrySet()) {
+ if (Func.isBlank(existArNos.get(entry.getKey()))) {
+ throw new ServiceException("推送金蝶开票申请失败:正式结算单[" + entry.getValue()
+ + "]财务应收单未在金蝶生成,请先在正式付款单页面同步金蝶");
+ }
+ }
+ // A303-4 幂等预检:按申请单号(billno 候选键)查询金蝶是否已生成开票申请单(防上次推送成功但本地回滚的断链重推)
+ BfiInvoiceApplyQueryDTO idempotentQuery = new BfiInvoiceApplyQueryDTO();
+ idempotentQuery.setPageNo(1);
+ idempotentQuery.setPageSize(10);
+ idempotentQuery.setBillno(List.of(entity.getApplicationNo()));
+ List existApplies = List.of();
+ BfiPageVO existPage = bfiService.queryInvoiceApply(idempotentQuery);
+ if (existPage != null && Func.isNotEmpty(existPage.getRows())) {
+ existApplies = existPage.getRows();
+ }
+ for (BfiInvoiceApplyVO existApply : existApplies) {
+ if (existApply != null && entity.getApplicationNo().equals(existApply.getBillno())) {
+ // 金蝶已生成:直接回填视为同步成功(勾稽纠偏)
+ entity.setKingdeeBillNo(existApply.getBillno());
+ entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
+ entity.setSyncedTime(LocalDateTime.now());
+ updateById(entity);
+ record(entity.getId(), "sync", "同步金蝶(勾稽回填)", APPROVED, APPROVED, null, existApply.getBillno());
+ return existApply.getBillno();
+ }
+ }
+ // 组装 A303-1 开票申请单(字段映射见设计文档 6.3/6.4)
+ BfiProperties.InvoiceApply invoiceApplyConfig = bfiProperties.getInvoiceApply();
+ if (Func.isBlank(entity.getIssuerName())) {
+ throw new ServiceException("推送金蝶开票申请失败:开票单位(销方)为空");
+ }
+ // 销方税号:按开票单位走客商档案取统一社会信用代码(与 A303-5 查询同源)
+ CustomerArchive seller = findCustomer(entity.getIssuerName());
+ if (seller == null || Func.isBlank(seller.getUnifiedCreditCode())) {
+ throw new ServiceException("推送金蝶开票申请失败:开票单位[" + Func.toStr(entity.getIssuerName())
+ + "]未在客商档案中维护统一社会信用代码,请补充后重试");
+ }
+ if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空");
+ if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空");
+ Dept dept = entity.getDeptId() == null ? null : SysCache.getDept(entity.getDeptId());
+ if (dept == null || Func.isBlank(dept.getDeptCode())) {
+ throw new ServiceException("推送金蝶开票申请失败:申请部门[" + Func.toStr(entity.getDeptId()) + "]未维护部门编码,请检查部门数据");
+ }
+ CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId());
+ if (customer == null || Func.isBlank(customer.getCustomerCode())) {
+ throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
+ }
+ // 商品行分录:发票张内商品行平铺(行性质固定商品行)
+ List items = new ArrayList<>();
+ List sheets = sheetMapper.selectList(Wrappers.lambdaQuery()
+ .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo));
+ BigDecimal amountNoTaxTotal = BigDecimal.ZERO;
+ BigDecimal taxTotal = BigDecimal.ZERO;
+ for (InvoiceApplicationSheet sheet : sheets) {
+ List sheetLines = lineMapper.selectList(Wrappers.lambdaQuery()
+ .eq(InvoiceApplicationLine::getInvoiceSheetId, sheet.getId()).orderByAsc(InvoiceApplicationLine::getLineNo));
+ for (InvoiceApplicationLine line : sheetLines) {
+ if (Func.isBlank(line.getTaxClassificationCode())) {
+ throw new ServiceException("推送金蝶开票申请失败:商品行[" + Func.toStr(line.getGoodsName())
+ + "]缺少税收分类编码,请编辑开票申请重新选择开票内容");
+ }
+ items.add(buildInvoiceApplyEntry(line, invoiceApplyConfig));
+ amountNoTaxTotal = amountNoTaxTotal.add(money(line.getAmountNoTax()));
+ taxTotal = taxTotal.add(money(line.getTaxAmount()));
+ }
+ }
+ if (Func.isEmpty(items)) throw new ServiceException("推送金蝶开票申请失败:开票申请无商品行,无法组装分录");
+ BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
+ // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
+ // 【必填】组织.编码:申请部门 deptId → 部门编码
+ param.setOrgidNumber(dept.getDeptCode());
+ // 【必填】申请部门.编码:同上
+ param.setAwrpOrgfieldNumber(dept.getDeptCode());
+ // 【必填】业务单据编号(候选键):开票申请单号
+ param.setBillno(entity.getApplicationNo());
+ // 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss
+ LocalDate billDate = entity.getApplicationDate() == null ? LocalDate.now() : entity.getApplicationDate();
+ param.setBilldate(billDate.atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+ // 【必填】单据性质:1 正数
+ param.setBillproperties("1");
+ // 【必填】发票种类:数电普票 10xdp / 数电专票 08xdp
+ param.setInvoicetype("electronic_special".equals(entity.getInvoiceType())
+ ? KingdeeBfiConstant.BFI_INVOICE_TYPE_DIGITAL_SPECIAL : KingdeeBfiConstant.BFI_INVOICE_TYPE_DIGITAL_GENERAL);
+ // 【必填】征税方式:0 普通征税
+ param.setTaxationstyle("0");
+ // 【必填】发票推送邮箱:部门邮箱
+ param.setBuyeremail(entity.getDepartmentEmails());
+ // 【必填】购方企业类型:0 企业
+ param.setBuyerproperty("0");
+ // 【必填】购方纳税人识别号:受票方开票信息快照
+ param.setBuyertaxno(entity.getTaxpayerNo());
+ // 【必填】购方名称:受票方单位
+ param.setBuyername(entity.getReceiverName());
+ // 【必填】销方名称:开票单位快照(结算单收款方带出)
+ param.setSalername(entity.getIssuerName());
+ // 【必填】销方纳税人识别号:开票单位 → 客商档案统一社会信用代码(A303-5 查询同源)
+ param.setSalertaxno(seller.getUnifiedCreditCode().trim());
+ // 【必填】申请价税合计:开票金额
+ param.setTotalamount(money(entity.getInvoiceAmount()));
+ // 【必填】客户.编码:受票方客商档案客商编码
+ param.setCustomnameidNumber(customer.getCustomerCode());
+ // 【必填】项目.项目编码(联调期临时传金蝶测试档案值 999)
+ param.setAwrpProjectNumber(KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE);
+ // ==================== 以下为非必填字段,先注释保留,联调确认后按需放开 ====================
+ // 申请金额(不含税):商品行不含税合计
+ // param.setInvoiceamount(amountNoTaxTotal);
+ // 申请税额:商品行税额合计
+ // param.setTotaltax(taxTotal);
+ // 数据来源:固定 3
+ // param.setBillsource("3");
+ // 来源单据:ar_finarbill 财务应收单
+ // param.setAwrpSrcBt(KingdeeBfiConstant.BFI_AR_BILL_SRC_BT);
+ // 上游外部系统单据号:第一张关联结算单的金蝶应收单号(多张拼接规则待联调确认)
+ // param.setAwrpSrcConNo(firstArBillNo);
+ // 数据来源系统:暂不传,待金蝶侧确认(联调校准项)
+
+ // 单据体分录:发票张内商品行平铺(缺失金蝶无法开票,联调主表通过后放开)
+ // param.setSimOriginalBillItem(items);
+
+ BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
+ kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶开票申请");
+ String kingdeeNo = saveResult.getResult().get(0).getNumber();
entity.setKingdeeBillNo(kingdeeNo);
- entity.setKingdeeStatus("synced");
+ entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED);
entity.setSyncedTime(LocalDateTime.now());
updateById(entity);
- record(entity.getId(), "sync", "同步金蝶", entity.getApprovalStatus(), entity.getApprovalStatus(), null, kingdeeNo);
+ record(entity.getId(), "sync", "同步金蝶", APPROVED, APPROVED, null, kingdeeNo);
return kingdeeNo;
}
+ /** A303-1 商品行分录组装:税率为 0-100 转小数文本,含税单价=价税合计/数量(数量为空时兜底 1) */
+ private BfiInvoiceApplySaveEntryDTO buildInvoiceApplyEntry(InvoiceApplicationLine line, BfiProperties.InvoiceApply config) {
+ BfiInvoiceApplySaveEntryDTO item = new BfiInvoiceApplySaveEntryDTO();
+ // 【必填】行性质:商品行;基准类型:数量基准
+ item.setRowtype("2");
+ item.setBenchmark("0");
+ // 【必填】商品名称 / 简称(商品和服务分类)
+ item.setGoodsname(line.getGoodsName());
+ item.setGoodssimplename(line.getGoodsCategory());
+ // 【必填】税收分类编码.编码:商品行开票内容快照(开票项目主数据) todo
+ // item.setTaxratecodeidNumber(line.getTaxClassificationCode());
+ item.setTaxratecodeidNumber("304060299");
+ // 数量/计量单位(行无单位字段时兜底)
+ BigDecimal quantity = line.getQuantity() != null && line.getQuantity().compareTo(BigDecimal.ZERO) > 0
+ ? line.getQuantity() : BigDecimal.ONE;
+ item.setNum(quantity);
+ item.setUnit(Func.isBlank(line.getUnit()) ? "个" : line.getUnit());
+ // 单价(含税)=价税合计/数量;金额(含税)=价税合计
+ item.setTaxunitprice(money(line.getTotalAmount()).divide(quantity, 6, RoundingMode.HALF_UP));
+ item.setTaxamount(money(line.getTotalAmount()));
+ // 金额(不含税)/单价(不含税)/税额
+ item.setAmount(money(line.getAmountNoTax()));
+ item.setUnitprice(money(line.getUnitPriceNoTax()));
+ item.setTax(money(line.getTaxAmount()));
+ // 税率:0-100 转小数文本(13 -> 0.13),0 税率输出 0.00
+ BigDecimal taxRate = line.getTaxRate() == null ? BigDecimal.ZERO : line.getTaxRate();
+ item.setTaxrate(taxRate.compareTo(BigDecimal.ZERO) == 0 ? "0.00" : taxRate.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
+ .stripTrailingZeros().toPlainString());
+ item.setRemark(line.getRemark());
+ // 外部系统来源分录标识:商品行id(雪花id天然唯一);【必填】外部单据类型(配置,默认 0KzXs)
+ item.setAwrpEntryCode(String.valueOf(line.getId()));
+ item.setAwrpSrcType(config.getSrcType());
+ return item;
+ }
+
+ @Override
+ public String syncKingdeeInvoiceResult() {
+ // 待回写开票申请:审批通过 + 已同步(invoiced 为终态不再轮询;failed 无真实金蝶单号不参与查询,纠偏走重推预检)
+ List applications = list(Wrappers.lambdaQuery()
+ .eq(InvoiceApplication::getApprovalStatus, APPROVED)
+ .eq(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED)
+ .isNotNull(InvoiceApplication::getKingdeeBillNo)
+ .ne(InvoiceApplication::getKingdeeBillNo, "")
+ .orderByAsc(InvoiceApplication::getId));
+ if (Func.isEmpty(applications)) {
+ return "金蝶开票结果回写:无待回写开票申请";
+ }
+ // 按 serialNo(金蝶开票申请单编号)分批(≤50/批)调 A303-5,销方税号与 A303-1 同源(开票单位→客商档案),按税号分组查询
+ Map applicationBySerialNo = new LinkedHashMap<>();
+ applications.forEach(application -> {
+ if (Func.isNotBlank(application.getKingdeeBillNo())) {
+ applicationBySerialNo.putIfAbsent(application.getKingdeeBillNo(), application);
+ }
+ });
+ Map sellerCache = new LinkedHashMap<>();
+ Map> serialNosBySellerTaxNo = new LinkedHashMap<>();
+ int unresolved = 0;
+ for (Map.Entry entry : applicationBySerialNo.entrySet()) {
+ InvoiceApplication application = entry.getValue();
+ String issuerName = Func.toStr(application.getIssuerName()).trim();
+ if (Func.isBlank(issuerName)) {
+ unresolved++;
+ log.warn("金蝶开票结果回写:开票申请[{}]开票单位为空,跳过", application.getApplicationNo());
+ continue;
+ }
+ CustomerArchive seller = sellerCache.computeIfAbsent(issuerName, this::findCustomer);
+ String sellerTaxNo = seller == null ? null : Func.toStr(seller.getUnifiedCreditCode()).trim();
+ if (Func.isBlank(sellerTaxNo)) {
+ unresolved++;
+ log.warn("金蝶开票结果回写:开票申请[{}]开票单位[{}]未在客商档案维护统一社会信用代码,跳过",
+ application.getApplicationNo(), issuerName);
+ continue;
+ }
+ serialNosBySellerTaxNo.computeIfAbsent(sellerTaxNo, key -> new ArrayList<>()).add(entry.getKey());
+ }
+ int invoiceWritten = 0;
+ int failed = 0;
+ int invoicedCount = 0;
+ for (Map.Entry> group : serialNosBySellerTaxNo.entrySet()) {
+ List groupSerialNos = group.getValue();
+ for (int from = 0; from < groupSerialNos.size(); from += INVOICE_RESULT_QUERY_BATCH) {
+ List batch = groupSerialNos.subList(from, Math.min(from + INVOICE_RESULT_QUERY_BATCH, groupSerialNos.size()));
+ try {
+ BfiInvoiceApplyOpenApiQueryDTO param = new BfiInvoiceApplyOpenApiQueryDTO();
+ param.setSerialNo(String.join(",", batch));
+ param.setSellerTaxpayerId(group.getKey());
+ BfiOpenApiResultVO> result = bfiService.queryInvoiceApplyInvoice(param);
+ if (result == null || !result.isSuccess() || result.getData() == null) {
+ failed++;
+ log.warn("金蝶开票结果回写批次查询未成功:serialNo=[{}]", String.join(",", batch));
+ continue;
+ }
+ for (Object billItem : result.getData()) {
+ if (!(billItem instanceof Map)) {
+ continue;
+ }
+ Map, ?> billMap = (Map, ?>) billItem;
+ String billNo = firstText(billMap, "billNo", "billno");
+ InvoiceApplication application = applicationBySerialNo.get(billNo);
+ if (application == null || !(billMap.get("invoiceList") instanceof List)) {
+ continue;
+ }
+ int inserted = 0;
+ for (Object invoiceItem : (List>) billMap.get("invoiceList")) {
+ if (!(invoiceItem instanceof Map)) {
+ continue;
+ }
+ Map, ?> invoice = (Map, ?>) invoiceItem;
+ // 宽松提取关键字段(票易通原文,字段名联调校准后收敛),原文整体 JSON 留档
+ String invoiceNo = firstText(invoice, "invoiceNo", "invoice_no", "invoiceNumber");
+ if (Func.isBlank(invoiceNo)) {
+ continue;
+ }
+ Long exists = invoiceApplicationInvoiceMapper.selectCount(
+ Wrappers.lambdaQuery()
+ .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, application.getId())
+ .eq(InvoiceApplicationInvoice::getInvoiceNo, invoiceNo));
+ if (exists != null && exists > 0) {
+ continue;
+ }
+ InvoiceApplicationInvoice record = new InvoiceApplicationInvoice();
+ record.setInvoiceApplicationId(application.getId());
+ record.setSerialNo(billNo);
+ record.setInvoiceNo(invoiceNo);
+ record.setInvoiceDate(parseInvoiceDate(firstText(invoice, "invoiceDate", "invoice_date", "issueDate", "billingDate")));
+ record.setInvoiceAmount(parseDecimal(firstText(invoice, "amountWithTax", "totalAmount", "invoiceAmount", "priceTaxTotal")));
+ record.setRawJson(JsonUtil.toJson(invoice));
+ invoiceApplicationInvoiceMapper.insert(record);
+ inserted++;
+ }
+ if (inserted > 0) {
+ invoiceWritten += inserted;
+ // 查到发票:开票申请单推进开票完成(终态)
+ application.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_INVOICED);
+ updateById(application);
+ invoicedCount++;
+ }
+ }
+ } catch (Exception exception) {
+ failed++;
+ log.error("金蝶开票结果回写批次异常:serialNo=[{}]", String.join(",", batch), exception);
+ }
+ }
+ }
+ return "金蝶开票结果回写完成:待查开票申请" + applications.size() + "笔(销方税号无法解析跳过" + unresolved
+ + "笔),回写发票" + invoiceWritten + "张(开票完成" + invoicedCount + "笔,失败批次" + failed + "个)";
+ }
+
+ /** 从票易通原文 Map 中按候选键顺序取首个非空文本值 */
+ private String firstText(Map, ?> map, String... keys) {
+ for (String key : keys) {
+ Object value = map.get(key);
+ if (value != null && Func.isNotBlank(String.valueOf(value))) {
+ return String.valueOf(value).trim();
+ }
+ }
+ return null;
+ }
+
+ /** 宽松解析开票日期文本(支持 yyyy-MM-dd HH:mm:ss / yyyy-MM-dd / ISO),无法解析返回 null */
+ private LocalDateTime parseInvoiceDate(String text) {
+ if (Func.isBlank(text)) {
+ return null;
+ }
+ String value = text.trim();
+ List formatters = List.of(
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
+ DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
+ DateTimeFormatter.ofPattern("yyyy/MM/dd"),
+ DateTimeFormatter.ISO_LOCAL_DATE_TIME,
+ DateTimeFormatter.ISO_LOCAL_DATE);
+ for (DateTimeFormatter formatter : formatters) {
+ try {
+ if (formatter.equals(DateTimeFormatter.ISO_LOCAL_DATE_TIME) || formatter.toString().contains("HH")) {
+ return LocalDateTime.parse(value, formatter);
+ }
+ return LocalDate.parse(value, formatter).atStartOfDay();
+ } catch (Exception ignored) {
+ // 尝试下一种格式
+ }
+ }
+ log.warn("金蝶开票结果开票日期解析失败:[{}]", text);
+ return null;
+ }
+
+ /** 宽松解析金额文本,无法解析返回 null */
+ private BigDecimal parseDecimal(String text) {
+ if (Func.isBlank(text)) {
+ return null;
+ }
+ try {
+ return new BigDecimal(text.trim()).setScale(2, RoundingMode.HALF_UP);
+ } catch (Exception exception) {
+ log.warn("金蝶开票结果金额解析失败:[{}]", text);
+ return null;
+ }
+ }
+
private InvoiceApplicationVO toListVO(InvoiceApplication entity) {
InvoiceApplicationVO vo = InvoiceApplicationWrapper.build().entityVO(entity);
vo.setSettlementNos(settlementRelationMapper.selectList(Wrappers.lambdaQuery()
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java
index 5528198b..0a7fc051 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java
@@ -58,6 +58,7 @@ public class InvoiceApplicationWrapper extends BaseEntityWrapper "未同步";
case "synced" -> "已同步";
+ case "invoiced" -> "开票完成";
case "failed" -> "同步失败";
default -> entity.getKingdeeStatus();
});
diff --git a/doc/sql/transport/blade_invoice_application_invoice_20260920.sql b/doc/sql/transport/blade_invoice_application_invoice_20260920.sql
new file mode 100644
index 00000000..12e77271
--- /dev/null
+++ b/doc/sql/transport/blade_invoice_application_invoice_20260920.sql
@@ -0,0 +1,22 @@
+-- 开票申请金蝶发票结果表(A303-5 定时回写落库,发票号唯一幂等)
+
+CREATE TABLE IF NOT EXISTS `blade_invoice_application_invoice` (
+ `id` bigint(20) NOT NULL COMMENT '主键',
+ `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID',
+ `create_user` bigint(20) DEFAULT NULL COMMENT '创建人',
+ `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_user` bigint(20) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `status` int(11) DEFAULT '1' COMMENT '状态',
+ `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除',
+ `invoice_application_id` bigint(20) NOT NULL COMMENT '开票申请ID',
+ `serial_no` varchar(64) DEFAULT NULL COMMENT '金蝶开票申请单编号(A303-5 serialNo)',
+ `invoice_no` varchar(64) NOT NULL COMMENT '发票号码',
+ `invoice_date` datetime DEFAULT NULL COMMENT '开票日期',
+ `invoice_amount` decimal(18,2) DEFAULT NULL COMMENT '价税合计',
+ `raw_json` text COMMENT 'A303-5返回原文JSON留档',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_application_invoice` (`tenant_id`,`invoice_application_id`,`invoice_no`),
+ KEY `idx_iai_application_id` (`invoice_application_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开票申请金蝶发票结果';
diff --git a/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql b/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql
new file mode 100644
index 00000000..dd06c8f5
--- /dev/null
+++ b/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql
@@ -0,0 +1,21 @@
+-- 应收结算与开票申请同步金蝶(A303-1 开票申请推送)
+-- 开票申请商品行新增字段:
+-- tax_classification_code 税收分类编码(开票项目主数据 blade_invoice_item.tax_classification_code,
+-- 新增/编辑开票申请选择开票内容时快照落库,A303-1 分录 taxratecodeid.number 取数来源)
+-- 销方名称/销方税号不走 Nacos 配置与表结构:销方名称取开票申请开票单位快照(issuer_name,结算单收款方带出),
+-- 销方税号按开票单位查客商档案统一社会信用代码(unified_credit_code)
+-- 开票申请基础建表脚本 blade_invoice_application_20260821.sql 保持不动,本脚本支持重复执行
+
+-- 以下结构升级支持重复执行
+SET @ddl_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `blade_invoice_application_line` ADD COLUMN `tax_classification_code` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''税收分类编码(开票内容快照)'' AFTER `goods_name`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'blade_invoice_application_line'
+ AND COLUMN_NAME = 'tax_classification_code'
+);
+PREPARE ddl_stmt FROM @ddl_sql;
+EXECUTE ddl_stmt;
+DEALLOCATE PREPARE ddl_stmt;