♻️ 开票申请同步金蝶分录组装改为纯页面商品行透传

resolveInvoiceApplyEntries 去掉开票项目主数据全量拉取(ISysClient.getInvoiceItems)与合同计费规则链路(开票明细→正式结算明细→应收费用行 billing_rules_json)两处回查,分录的分类/简称/税收分类编码/税率/金额/税额/单价/备注全部取商品行落库值,行缺编码或缺税率直接报错;取数收敛为张次+商品行两条查询,避免逐张 N+1。
This commit is contained in:
刘泉佳
2026-09-22 10:51:49 +08:00
parent 75d5b8cf81
commit 3edefa9b4b
2 changed files with 81 additions and 121 deletions
@@ -64,7 +64,7 @@ public class InvoiceApplicationLine extends TenantEntity {
*/
private String goodsName;
/**
* 税收分类编码(选择开票内容时快照,仅供展示与追溯;推送金蝶按分类+简称实时匹配开票项目主数据取数)
* 税收分类编码(选择开票内容时快照,推送金蝶 A303-1 分录直接取该值)
*/
private String taxClassificationCode;
/**
@@ -34,14 +34,11 @@ 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.api.R;
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.feign.ISysClient;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.InvoiceItem;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO;
@@ -53,11 +50,11 @@ 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.ContractManageMapper;
import org.springblade.transport.mapper.CustomerArchiveMapper;
import org.springblade.transport.mapper.CustomerContactMapper;
import org.springblade.transport.mapper.CustomerInvoiceContactMapper;
import org.springblade.transport.mapper.CustomerInvoiceInfoMapper;
import org.springblade.transport.mapper.ContractManageMapper;
import org.springblade.transport.mapper.FormalSettlementDetailMapper;
import org.springblade.transport.mapper.FormalSettlementMapper;
import org.springblade.transport.mapper.InvoiceApplicationDetailMapper;
@@ -70,11 +67,11 @@ import org.springblade.transport.mapper.InvoiceApplicationSheetMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.dto.InvoiceApplicationSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceApplicationStatusRequest;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerArchive;
import org.springblade.transport.pojo.entity.CustomerContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
import org.springblade.transport.pojo.entity.InvoiceApplication;
@@ -85,14 +82,13 @@ 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;
import org.springblade.transport.pojo.vo.InvoiceApplicationVO;
import org.springblade.transport.service.IInvoiceApplicationService;
import org.springblade.transport.service.KingdeeSyncContext;
import org.springblade.transport.support.KingdeeApBillSyncSupport;
import org.springblade.transport.support.KingdeeArBillSyncSupport;
import org.springblade.transport.wrapper.InvoiceApplicationWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -104,6 +100,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -131,8 +128,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
private static final String VOIDED = "voided";
/** A303-5 开票结果批量查询单批单据编号上限(接口限制 50) */
private static final int INVOICE_RESULT_QUERY_BATCH = 50;
/** 开票申请分录品目:应收货物费用行按运费开票,与开票项目「货物或服务简称」匹配 */
private static final String INVOICE_FEE_NAME_FREIGHT = "运费";
private final InvoiceApplicationSettlementMapper settlementRelationMapper;
private final InvoiceApplicationSheetMapper sheetMapper;
private final WaybillMapper waybillMapper;
@@ -151,7 +146,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
private final BfiProperties bfiProperties;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final ISysClient sysClient;
@Override
public IPage<InvoiceApplicationVO> selectPage(IPage<InvoiceApplication> page, InvoiceApplicationVO query) {
@@ -571,8 +565,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
if (customer == null || Func.isBlank(customer.getCustomerCode())) {
throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试");
}
// 单据体分录:按开票明细→应收明细→费用明细展开(费用项名称聚合行),开票内容页面商品行明细数据不可信,
// 仅以其开票金额合计为准(分录含税金额按费用归集占比分摊,尾差进最后一行)
// 单据体分录:逐「开票内容」商品行一条,取数口径即页面开票内容行(金额/税率透传行值)
List<BfiInvoiceApplySaveEntryDTO> items = this.resolveInvoiceApplyEntries(entity);
BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO();
// ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ====================
@@ -628,7 +621,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
// 数据来源系统
param.setSystemsource(bfiProperties.getSrcSys());
// 单据体分录:费用明细展开聚合行(联调确认金蝶强校验「单据体分录不能为空」,实际必传)
// 单据体分录:逐「开票内容」商品行一条(联调确认金蝶强校验「单据体分录不能为空」,实际必传)
param.setSimOriginalBillItem(items);
BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param);
@@ -643,121 +636,88 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl<InvoiceApplic
}
/**
* A303-1 单据体分录组装:开票明细 → 正式结算明细 → 应收货物费用行(复用财务应收单 resolveArEntryGroups,
* 同链路同粒度:每条费用行一条分录不聚合,awrp_entry_code=费用行id,与应收单分录逐行勾稽);
* 开票内容页面商品行明细不可信,仅以申请单开票金额合计为准(分录含税金额按费用行金额占比分摊,尾差进最后一行);
* 开票品目按「运费」简称实时匹配开票项目主数据(分类/税收分类编码/默认税率均来自该档案)
* A303-1 单据体分录组装:逐「开票内容」商品行一条(按发票张次、张内行号平铺),
* 商品和服务分类、货物或服务简称、税收分类编码、数量、单位、税率、金额、税额、备注全部取商品行落库值,
* 不再回查开票项目主数据与合同计费规则
*/
private List<BfiInvoiceApplySaveEntryDTO> resolveInvoiceApplyEntries(InvoiceApplication entity) {
List<InvoiceApplicationDetail> applicationDetails = applicationDetailMapper.selectList(Wrappers.<InvoiceApplicationDetail>lambdaQuery()
.eq(InvoiceApplicationDetail::getInvoiceApplicationId, entity.getId())
.orderByAsc(InvoiceApplicationDetail::getLineNo));
if (Func.isEmpty(applicationDetails)) {
throw new ServiceException("推送金蝶开票申请失败:开票申请无开票明细,无法按费用明细组装分录");
}
List<Long> settlementDetailIds = applicationDetails.stream().map(InvoiceApplicationDetail::getFormalSettlementDetailId)
.filter(Objects::nonNull).distinct().toList();
if (Func.isEmpty(settlementDetailIds)) {
throw new ServiceException("推送金蝶开票申请失败:开票明细缺少来源正式结算明细,请重新选择开票明细");
}
List<FormalSettlementDetail> settlementDetails = formalSettlementDetailMapper.selectBatchIds(settlementDetailIds).stream()
.filter(detail -> !Objects.equals(detail.getIsDeleted(), 1)).toList();
// 明细分录:每条应收货物费用行一条(不聚合,无吨位时数量兜底 1),与财务应收单(A302-2)分录同一取数
KingdeeSyncContext context = KingdeeSyncContext.empty();
List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups = kingdeeArBillSyncSupport.resolveArEntryGroups(settlementDetails, context);
if (Func.isEmpty(entryGroups)) {
throw new ServiceException("推送金蝶开票申请失败:开票明细费用行均为空,无法组装分录");
}
InvoiceItem freightItem = this.invoiceItemByShortName().get(INVOICE_FEE_NAME_FREIGHT);
if (freightItem == null || Func.isBlank(freightItem.getTaxClassificationCode())) {
throw new ServiceException("推送金蝶开票申请失败:开票项目页面未维护简称[" + INVOICE_FEE_NAME_FREIGHT
+ "]的启用的开票项目(或税收分类编码为空),请补充后重试");
}
BigDecimal taxRate = money(freightItem.getDefaultTaxRate());
BigDecimal targetTotal = money(entity.getInvoiceAmount());
BigDecimal sourceTotal = entryGroups.stream().map(KingdeeApBillSyncSupport.BfiEntryGroup::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
List<InvoiceApplicationSheet> sheets = sheetMapper.selectList(Wrappers.<InvoiceApplicationSheet>lambdaQuery()
.eq(InvoiceApplicationSheet::getInvoiceApplicationId, entity.getId()).orderByAsc(InvoiceApplicationSheet::getSheetNo));
Map<Long, List<InvoiceApplicationLine>> linesBySheetId = lineMapper.selectList(Wrappers.<InvoiceApplicationLine>lambdaQuery()
.eq(InvoiceApplicationLine::getInvoiceApplicationId, entity.getId())).stream()
.collect(Collectors.groupingBy(InvoiceApplicationLine::getInvoiceSheetId));
List<BfiInvoiceApplySaveEntryDTO> items = new ArrayList<>();
BigDecimal allocated = BigDecimal.ZERO;
for (int index = 0; index < entryGroups.size(); index++) {
KingdeeApBillSyncSupport.BfiEntryGroup entryGroup = entryGroups.get(index);
// 含税金额:以页面开票金额合计为准按费用行金额占比分摊,尾差由最后一行兜底
BigDecimal taxAmount = index == entryGroups.size() - 1 ? targetTotal.subtract(allocated)
: sourceTotal.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO
: money(entryGroup.amount()).multiply(targetTotal).divide(sourceTotal, 2, RoundingMode.HALF_UP);
allocated = allocated.add(taxAmount);
// 不含税金额 = 价税合计 ÷ (1 + 税率),税额 = 差额(税率为小数,直接参与换算)
BigDecimal amountNoTax = taxAmount.divide(BigDecimal.ONE.add(taxRate), 2, RoundingMode.HALF_UP);
BigDecimal quantity = entryGroup.quantity() != null && entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0
? entryGroup.quantity() : BigDecimal.ONE;
BfiInvoiceApplySaveEntryDTO item = new BfiInvoiceApplySaveEntryDTO();
// 【必填】行性质:2 商品行(固定;联调校准项)
item.setRowtype("2");
// 【必填】基准类型:0 数量基准(固定,按数量开票)
item.setBenchmark("0");
// 【必填】商品名称:开票项目「货物或服务简称」(运费)
item.setGoodsname(freightItem.getShortName());
// 【必填】货物或服务简称(商品和服务分类):开票项目「商品和服务分类名称」
item.setGoodssimplename(freightItem.getCategoryName());
// 【必填】税收分类编码.编码:开票项目「税收分类编码」
String taxratecodeidNumber = freightItem.getTaxClassificationCode().trim();
// todo test
taxratecodeidNumber = "304060299";
item.setTaxratecodeidNumber(taxratecodeidNumber);
// 税收分类合并编码(联调确认必传):开票项目「税收分类编码」,如有补位规则联调后调整
item.setGoodscode(taxratecodeidNumber);
// 数量:应收货物费用行运输数量(与财务应收单分录同源,无吨位兜底 1;折扣行才填 0)
item.setNum(quantity);
// 计量单位:费用行运费计算单位(兜底数量单位;档案暂无编码传名称,空兜底「次」)
item.setUnit(Func.isBlank(entryGroup.measureunitNumber()) ? "次" : entryGroup.measureunitNumber());
// 规格型号:费用行无稳定规格来源,不传
// 费用项目.编码:金蝶侧无对应档案映射,不传
// 单价(含税):分摊后含税金额 ÷ 数量(6 位小数)
item.setTaxunitprice(taxAmount.divide(quantity, 6, RoundingMode.HALF_UP));
// 金额(含税):费用行调整后总金额按占比分摊到开票金额合计(Σ分录 = 申请价税合计,尾差最后一行)
item.setTaxamount(taxAmount);
// 金额(不含税):含税金额 ÷ (1 + 税率)(2 位小数)
item.setAmount(amountNoTax);
// 单价(不含税):不含税金额 ÷ 数量(6 位小数)
item.setUnitprice(amountNoTax.divide(quantity, 6, RoundingMode.HALF_UP));
// 税额:含税 − 不含税差额
item.setTax(taxAmount.subtract(amountNoTax));
// 税率:开票项目「默认税率」直接透传(库中即小数值:0.13/0.06,money 后两位文本 0 -> "0.00")
item.setTaxrate(taxRate.toPlainString());
// 优惠政策标识/内容:本期无优惠场景,不传
// 剩余可申请数量/金额/税额:差额征税抵扣场景使用,不传
// 商品编码/物料编码:按商品类型(producttype)判定填写,本期品目走开票项目,不传
// 明细扣除额:差额征税场景使用,不传
// 行备注:费用行备注无开票语义,不传
// 【必填】外部系统来源分录标识(候选键):应收货物费用行id,与财务应收单(A302-2)分录逐行勾稽
item.setAwrpEntryCode(entryGroup.entryCode());
// 【必填】分录外部单据类型:固定 0KzXs(配置 thirdParty.bfi.invoiceApply.srcType)
item.setAwrpSrcType(bfiProperties.getInvoiceApply().getSrcType());
// 上游分录号:指向财务应收单分录行,回填规则待联调确认,本期不传
items.add(item);
for (InvoiceApplicationSheet sheet : sheets) {
linesBySheetId.getOrDefault(sheet.getId(), List.of()).stream()
.sorted(Comparator.comparing(InvoiceApplicationLine::getLineNo,
Comparator.nullsLast(Comparator.naturalOrder())))
.forEach(line -> items.add(buildInvoiceApplyEntry(line)));
}
if (Func.isEmpty(items)) {
throw new ServiceException("推送金蝶开票申请失败:开票申请无商品行,无法组装分录");
}
return items;
}
/** 开票项目主数据「货物或服务简称 -> 开票项目」映射(每次同步远程加载一次;同名编码不一致时拦截) */
private Map<String, InvoiceItem> invoiceItemByShortName() {
R<List<InvoiceItem>> response = sysClient.getInvoiceItems();
if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) {
throw new ServiceException("推送金蝶开票申请失败:开票项目主数据读取失败,请稍后重试");
/** 单条商品行生成一条开票分录:品目三要素、税率、金额、税额全部透传页面「开票内容」行值 */
private BfiInvoiceApplySaveEntryDTO buildInvoiceApplyEntry(InvoiceApplicationLine line) {
// 税收分类编码:直接取商品行「开票内容」选择的开票项目快照
if (Func.isBlank(line.getTaxClassificationCode())) {
throw new ServiceException("推送金蝶开票申请失败:商品行[" + Func.toStr(line.getGoodsCategory()) + "/" + Func.toStr(line.getGoodsName())
+ "]缺少税收分类编码,请编辑开票申请重新选择开票内容");
}
Map<String, InvoiceItem> itemByShortName = new LinkedHashMap<>();
for (InvoiceItem invoiceItem : response.getData()) {
if (invoiceItem == null || Func.isBlank(invoiceItem.getShortName())) {
continue;
}
String shortName = invoiceItem.getShortName().trim();
InvoiceItem existing = itemByShortName.putIfAbsent(shortName, invoiceItem);
if (existing != null && !Func.toStr(existing.getTaxClassificationCode()).trim()
.equals(Func.toStr(invoiceItem.getTaxClassificationCode()).trim())) {
throw new ServiceException("推送金蝶开票申请失败:开票项目[" + shortName
+ "]在开票项目页面维护了多个税收分类编码,请检查档案数据");
}
if (line.getTaxRate() == null) {
throw new ServiceException("推送金蝶开票申请失败:商品行[" + Func.toStr(line.getGoodsName())
+ "]缺少税率,请编辑开票申请补全税率后重试");
}
return itemByShortName;
String taxClassificationCode = line.getTaxClassificationCode().trim();
// todo test
taxClassificationCode = "304060299";
// 税率:页面维护的 0-100 百分数转小数(8 -> "0.08")
BigDecimal taxRate = money(line.getTaxRate()).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP).stripTrailingZeros();
String taxRateText = taxRate.compareTo(BigDecimal.ZERO) == 0 ? "0.00" : taxRate.toPlainString();
// 含税金额/不含税金额/税额/数量/单位:取商品行(数量为0兜底1)
BigDecimal taxAmount = money(line.getTotalAmount());
BigDecimal amountNoTax = money(line.getAmountNoTax());
BigDecimal tax = money(line.getTaxAmount());
BigDecimal quantity = line.getQuantity() != null && line.getQuantity().compareTo(BigDecimal.ZERO) > 0
? line.getQuantity() : BigDecimal.ONE;
BfiInvoiceApplySaveEntryDTO item = new BfiInvoiceApplySaveEntryDTO();
// 【必填】行性质:2 商品行(固定;联调校准项)
item.setRowtype("2");
// 【必填】基准类型:0 数量基准(固定,按数量开票)
item.setBenchmark("0");
// 【必填】商品名称:商品行「货物或服务简称」
item.setGoodsname(line.getGoodsName());
// 【必填】货物或服务简称(商品和服务分类):商品行「商品和服务分类」
item.setGoodssimplename(line.getGoodsCategory());
// 税收分类编码.编码 + 税收分类合并编码(联调确认均必传,同取商品行开票内容快照)
item.setTaxratecodeidNumber(taxClassificationCode);
item.setGoodscode(taxClassificationCode);
// 数量/计量单位:商品行数量(兜底1)与单位(空兜底「个」)
item.setNum(quantity);
item.setUnit(Func.isBlank(line.getUnit()) ? "个" : line.getUnit());
// 单价(不含税):商品行不含税单价;单价(含税):含税金额 ÷ 数量
item.setUnitprice(money(line.getUnitPriceNoTax()));
// item.setTaxunitprice(taxAmount.divide(quantity, 6, RoundingMode.HALF_UP));
// 金额(含税)/金额(不含税)/税额:商品行价税合计、不含税金额、税额
// item.setTaxamount(taxAmount);
item.setAmount(amountNoTax);
item.setTax(tax);
// 税率:商品行税率(0-100 百分数转小数) 蝶税率枚举只有 0.13/0.09/0.06/0.05/0.03/0.01/0
item.setTaxrate(taxRateText);
// 备注:商品行备注
item.setRemark(line.getRemark());
// 规格型号/费用项目.编码/优惠政策/剩余可申请/商品编码/物料编码/明细扣除额/上游分录号:本期不传
// 【必填】外部系统来源分录标识(候选键):商品行id(雪花id天然唯一)
item.setAwrpEntryCode(String.valueOf(line.getId()));
// 【必填】分录外部单据类型:固定 0KzXs(配置 thirdParty.bfi.invoiceApply.srcType)
item.setAwrpSrcType(bfiProperties.getInvoiceApply().getSrcType());
log.info("金蝶开票申请分录生成:商品行[{}],分类[{}],简称[{}],税收分类编码[{}],税率[{}],数量[{}],含税金额[{}],不含税金额[{}],税额[{}],分录标识[{}]",
line.getId(), item.getGoodssimplename(), item.getGoodsname(), item.getTaxratecodeidNumber(), taxRateText,
quantity, taxAmount, amountNoTax, tax, item.getAwrpEntryCode());
return item;
}
@Override