✨ 开票申请同步金蝶A303-1并回写A303-5开票结果(销方与税收分类编码随单据取数,新增发票结果表)

This commit is contained in:
刘泉佳
2026-09-21 19:14:20 +08:00
parent 82b347cb18
commit 39ffac15e2
11 changed files with 541 additions and 4 deletions
@@ -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;
@@ -0,0 +1,67 @@
/**
* 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.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;
/**
* 开票申请金蝶发票结果实体类
* <p>
* 定时任务经 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;
}
@@ -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;
@@ -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<InvoiceApplicationSheetVO> sheets;
@TableField(exist = false) private List<InvoiceApplicationDetail> details;
@TableField(exist = false) private List<InvoiceApplicationRecord> records;
@TableField(exist = false) private List<InvoiceApplicationInvoice> invoices;
}
@@ -156,4 +156,11 @@ public class InvoiceApplicationController extends BladeController {
public R<String> syncKingdee(@RequestParam Long id) {
return R.data(invoiceApplicationService.syncKingdee(id));
}
@PostMapping("/sync-kingdee-invoice-result")
@ApiOperationSupport(order = 13)
@Operation(summary = "手动回写金蝶开票结果(A303-5)")
public R<String> syncKingdeeInvoiceResult() {
return R.data(invoiceApplicationService.syncKingdeeInvoiceResult());
}
}
@@ -0,0 +1,32 @@
/**
* 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.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
/**
* 开票申请金蝶发票结果 Mapper
*
* @author Chill
*/
public interface InvoiceApplicationInvoiceMapper extends BaseMapper<InvoiceApplicationInvoice> {
}
@@ -55,4 +55,5 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
void returnBill(InvoiceApplicationStatusRequest request);
void voidBill(InvoiceApplicationStatusRequest request);
String syncKingdee(Long id);
String syncKingdeeInvoiceResult();
}
@@ -30,11 +30,26 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.SysCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveEntryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.constant.KingdeeBfiConstant;
import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.CustomerContactMapper;
import org.springblade.transport.mapper.CustomerInvoiceContactMapper;
@@ -43,6 +58,7 @@ import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceApplicationDetailMapper;
import org.springblade.transport.mapper.InvoiceApplicationInvoiceMapper;
import org.springblade.transport.mapper.InvoiceApplicationLineMapper;
import org.springblade.transport.mapper.InvoiceApplicationMapper;
import org.springblade.transport.mapper.InvoiceApplicationRecordMapper;
@@ -60,11 +76,14 @@ import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
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.InvoiceApplicationLine;
import org.springblade.transport.pojo.entity.InvoiceApplicationRecord;
import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement;
import org.springblade.transport.pojo.entity.InvoiceApplicationSheet;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.pojo.vo.CustomerInvoiceContactVO;
import org.springblade.transport.pojo.vo.CustomerInvoiceInfoVO;
import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO;
@@ -97,6 +116,7 @@ import java.util.stream.Collectors;
* @author Chill
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplicationMapper, InvoiceApplication>
implements IInvoiceApplicationService {
@@ -105,6 +125,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
private static final String APPROVED = "approved";
private static final String RETURNED = "returned";
private static final String VOIDED = "voided";
/** A303-5 开票结果批量查询单批单据编号上限(接口限制 50) */
private static final int INVOICE_RESULT_QUERY_BATCH = 50;
private final InvoiceApplicationSettlementMapper settlementRelationMapper;
private final InvoiceApplicationSheetMapper sheetMapper;
private final WaybillMapper waybillMapper;
@@ -118,6 +140,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
private final CustomerInvoiceInfoMapper customerInvoiceInfoMapper;
private final CustomerInvoiceContactMapper customerInvoiceContactMapper;
private final CustomerContactMapper customerContactMapper;
private final InvoiceApplicationInvoiceMapper invoiceApplicationInvoiceMapper;
private final IBfiService bfiService;
private final BfiProperties bfiProperties;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
@Override
public IPage<InvoiceApplicationVO> selectPage(IPage<InvoiceApplication> page, InvoiceApplicationVO query) {
@@ -151,6 +178,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
vo.setRecords(recordMapper.selectList(Wrappers.<InvoiceApplicationRecord>lambdaQuery()
.eq(InvoiceApplicationRecord::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationRecord::getCreateTime)));
vo.setInvoices(invoiceApplicationInvoiceMapper.selectList(Wrappers.<InvoiceApplicationInvoice>lambdaQuery()
.eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id)
.orderByAsc(InvoiceApplicationInvoice::getCreateTime)));
return vo;
}
@@ -441,16 +471,368 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
public String syncKingdee(Long id) {
InvoiceApplication entity = existing(id);
if (!APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶");
if ("synced".equals(entity.getKingdeeStatus())) return entity.getKingdeeBillNo();
String kingdeeNo = "K3INV" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
// 已同步/已开票幂等返回(开票完成为终态)
if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus())
|| KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())) {
return entity.getKingdeeBillNo();
}
if (Func.isBlank(entity.getApplicationNo())) throw new ServiceException("推送金蝶开票申请失败:开票申请单号为空");
// 前置校验:关联正式结算单的金蝶应收单已生成(正式结算开票须先推财务应收单),经 A302-4 按防重键以金蝶侧实际生成为准
List<InvoiceApplicationSettlement> relations = settlementRelationMapper.selectList(
Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
.eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id));
if (Func.isEmpty(relations)) throw new ServiceException("推送金蝶开票申请失败:开票申请未关联正式结算单");
List<String> arSrcCodes = new ArrayList<>();
String firstArBillNo = null;
Map<String, String> 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<FormalSettlementDetail> details = formalSettlementDetailMapper.selectList(
Wrappers.<FormalSettlementDetail>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<String, String> existArNos = kingdeeArBillSyncSupport.queryArBillExistNos(arSrcCodes);
for (Map.Entry<String, String> 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<BfiInvoiceApplyVO> existApplies = List.of();
BfiPageVO<BfiInvoiceApplyVO> 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<BfiInvoiceApplySaveEntryDTO> items = new ArrayList<>();
List<InvoiceApplicationSheet> sheets = sheetMapper.selectList(Wrappers.<InvoiceApplicationSheet>lambdaQuery()
.eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo));
BigDecimal amountNoTaxTotal = BigDecimal.ZERO;
BigDecimal taxTotal = BigDecimal.ZERO;
for (InvoiceApplicationSheet sheet : sheets) {
List<InvoiceApplicationLine> sheetLines = lineMapper.selectList(Wrappers.<InvoiceApplicationLine>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<InvoiceApplication> applications = list(Wrappers.<InvoiceApplication>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<String, InvoiceApplication> applicationBySerialNo = new LinkedHashMap<>();
applications.forEach(application -> {
if (Func.isNotBlank(application.getKingdeeBillNo())) {
applicationBySerialNo.putIfAbsent(application.getKingdeeBillNo(), application);
}
});
Map<String, CustomerArchive> sellerCache = new LinkedHashMap<>();
Map<String, List<String>> serialNosBySellerTaxNo = new LinkedHashMap<>();
int unresolved = 0;
for (Map.Entry<String, InvoiceApplication> 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<String, List<String>> group : serialNosBySellerTaxNo.entrySet()) {
List<String> groupSerialNos = group.getValue();
for (int from = 0; from < groupSerialNos.size(); from += INVOICE_RESULT_QUERY_BATCH) {
List<String> 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<List<Object>> 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.<InvoiceApplicationInvoice>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<DateTimeFormatter> 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.<InvoiceApplicationSettlement>lambdaQuery()
@@ -58,6 +58,7 @@ public class InvoiceApplicationWrapper extends BaseEntityWrapper<InvoiceApplicat
vo.setKingdeeStatusName(switch (entity.getKingdeeStatus() == null ? "" : entity.getKingdeeStatus()) {
case "unsynced" -> "未同步";
case "synced" -> "已同步";
case "invoiced" -> "开票完成";
case "failed" -> "同步失败";
default -> entity.getKingdeeStatus();
});
@@ -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='开票申请金蝶发票结果';
@@ -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;