From b7beb949fb74f9d84200091e8a1e84923705a2c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com>
Date: Thu, 1 Oct 2026 23:15:40 +0800
Subject: [PATCH] =?UTF-8?q?perf:=20=E5=88=86=E5=BD=95=E6=8B=86=E5=88=86?=
=?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=8A=E4=B8=8B=E6=96=87=E7=BC=93=E5=AD=98?=
=?UTF-8?q?=E6=B6=88=E9=99=A4N+1=EF=BC=8C=E8=81=94=E8=B0=83=E8=AF=A6?=
=?UTF-8?q?=E7=BB=86=E6=97=A5=E5=BF=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
N+1 治理(复用 KingdeeSyncContext 全批档案缓存,批量同步不因拆分逻辑增加重复查询):
- 合同计费方案解析按合同id缓存(BillingPlanInfo:计费单位+费用归类一次解析全批复用),
消除批量同步逐单重复解析同一合同计费方案JSON(此前计费单位/归类各解析一次)
- 货物费用行按结算明细id上下文缓存(含空行占位,仅补查缺失明细),多结算单单据
(收票登记分摊多结算单)跨结算单一次预查,逐结算单解析命中缓存
- 结算合计费用项按结算单id上下文缓存,多笔收票登记分摊同一结算单不再重复查询
- KingdeeSyncContext 新增 billingPlanCache/detailFeeCache/summaryFeeCache 组件
联调详细日志:
- 逐费用项归类明细(同单去重):费用项->费用大类/税率+归类来源(计费方案规则/费用项档案/
名称特征兜底/交叉补齐);来源为名称兜底或税率未识别时升级 warn 提示补配置
- 分录生成日志增加费用项构成(运输费:100.00 + 装卸费:50.00)、货物、车号
- 货物费用行批量查询打印缓存命中/补查行数
---
.../transport/service/KingdeeSyncContext.java | 21 ++-
.../support/KingdeeApBillSyncSupport.java | 153 ++++++++++++++----
2 files changed, 143 insertions(+), 31 deletions(-)
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java
index 4a14535c..848e17af 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java
@@ -29,15 +29,24 @@ import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerReceiptAccount;
import org.springblade.transport.pojo.entity.FormalSettlement;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
+import org.springblade.transport.pojo.entity.FormalSettlementDetailFee;
+import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee;
import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springblade.transport.support.KingdeeApBillSyncSupport;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
- * 金蝶同步上下文:批量同步时全批共享的档案缓存,消除逐单重复的远程/数据库查询(N+1)。
- *
单笔同步使用 {@link #empty()},各缓存为空时自动回退为逐单查询,行为与原逻辑一致。
+ * 金蝶同步上下文:批量同步时全批共享的档案缓存,消除逐单重复的远程/数据库查询与重复解析(N+1)。
+ * 缓存清单与加载来源:
+ * 计量单位档案(ISysClient.getMeasurementUnits)、费用项档案(ISysClient.getFeeItems)、
+ * 客商编码(客商档案按乙方/付款方查询)、合同/项目/正式结算单(selectById)、
+ * 结算明细(按结算单批量)、财务应付单已生成预检(A102-4 批量查询)、
+ * 收款账户、合同计费方案解析({@link KingdeeApBillSyncSupport.BillingPlanInfo},按合同id解析一次)、
+ * 货物费用行(按结算明细id查询,键全量占位避免空行重复查询)、结算合计费用项(按结算单id查询)。
+ * 单笔同步使用 {@link #empty()},各缓存为空时首单加载后即复用。
*
* @author Chill
*/
@@ -47,11 +56,15 @@ public record KingdeeSyncContext(Map measureUnitCodeByName, Map<
Map> settlementDetailCache,
Map settlementCache,
Map receiptAccountCache,
- Map feeItemCacheByName) {
+ Map feeItemCacheByName,
+ Map billingPlanCache,
+ Map> detailFeeCache,
+ Map> summaryFeeCache) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
public static KingdeeSyncContext empty() {
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(),
- new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
+ new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(),
+ new HashMap<>(), new HashMap<>(), new HashMap<>());
}
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java
index e417d376..a27e8c90 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java
@@ -69,10 +69,12 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@@ -89,6 +91,10 @@ import java.util.stream.Collectors;
* 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber});
* 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码;
* 数量运输费大类取货物运输量、其余默认 1;单价按结算金额/数量反算。
+ * 缓存与 N+1:单笔/批量同步共用 {@link KingdeeSyncContext} 全批档案缓存——合同/项目/客商/正式结算单/
+ * 应付单预检/计量单位档案/费用项档案走既有缓存,计费方案解析({@link BillingPlanInfo})按合同id解析一次、
+ * 货物费用行按结算明细id、结算合计费用项按结算单id缓存复用,
+ * 批量同步(如收票单批量推送分摊多结算单)不因拆分逻辑增加重复查询与重复解析。
*
* @author Chill
*/
@@ -111,6 +117,14 @@ public class KingdeeApBillSyncSupport {
private static final String FREIGHT_FEE_CATEGORY = "运输费";
/** 分录金额勾稽允许误差(页面金额与费用项合计的分摊尾差容忍度) */
private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
+ /** 费用归类来源:合同计费方案规则 */
+ private static final String PLAN_FEE_SOURCE = "合同计费方案规则";
+ /** 费用归类来源:费用项档案 */
+ private static final String ARCHIVE_FEE_SOURCE = "费用项档案";
+ /** 费用归类来源:名称特征兜底(计费方案与费用项档案均未维护,告警提示补配置) */
+ private static final String FALLBACK_FEE_SOURCE = "名称特征兜底";
+ /** 费用归类来源:合同计费方案税率 + 费用项档案大类(交叉补齐) */
+ private static final String MIXED_FEE_SOURCE = "合同计费方案税率+费用项档案大类";
private final IBfiService bfiService;
private final ContractManageMapper contractManageMapper;
@@ -436,6 +450,9 @@ public class KingdeeApBillSyncSupport {
detailsBySettlementId.computeIfAbsent(settlementDetail.getFormalSettlementId(), key -> new ArrayList<>()).add(settlementDetail);
}
List entryGroups = new ArrayList<>();
+ // 跨结算单一次批量预查全部货物费用行入上下文缓存:逐结算单解析时命中缓存,
+ // 消除多结算单单据(收票登记分摊多结算单)逐结算单重复查询的 N+1
+ detailFeesByDetailId(settlementDetails, context);
detailsBySettlementId.forEach((settlementId, details) ->
entryGroups.addAll(this.resolveEntryGroups(requireSettlement(settlementId, context), details, context)));
return entryGroups;
@@ -467,16 +484,16 @@ public class KingdeeApBillSyncSupport {
}
ProjectApply project = requireProject(settlement.getProjectId(), context);
ContractManage contract = requireContract(settlement.getContractId(), context);
- // 合同计费方案规则:费用类型/费用项 -> 计费单位
- Map billingUnitByRule = billingUnitByRule(contract);
- // 合同计费方案规则:费用项 -> (费用大类, 税率),结算明细费用项拆分归类主来源
- Map feeClassByRule = feeClassByRule(contract);
- // 费用项档案:费用项名称 -> 档案(费用大类/税率兜底来源)
+ // 合同计费方案解析(上下文缓存:按合同id解析一次全批复用,消除批量同步逐单重复解析 JSON 的 N+1)
+ BillingPlanInfo billingPlan = billingPlanInfo(contract, context);
+ Map billingUnitByRule = billingPlan.billingUnitByRule();
+ Map feeClassByRule = billingPlan.feeClassByRule();
+ // 费用项档案:费用项名称 -> 档案(费用大类/税率兜底来源,上下文缓存全批复用)
Map feeItemArchive = feeItemArchiveByName(context);
- // 计量单位档案:单位名称 -> 计量单位编码
+ // 计量单位档案:单位名称 -> 计量单位编码(上下文缓存全批复用)
Map unitCodeByName = measureUnitCodeByName(context);
- // 结算明细货物费用行:一车多货按货物拆分与费用项归类取数来源
- Map> feesByDetailId = detailFeesByDetailId(settlementDetails);
+ // 结算明细货物费用行:一车多货按货物拆分与费用项归类取数来源(上下文缓存:跨结算单/跨单据复用,仅补查缺失明细)
+ Map> feesByDetailId = detailFeesByDetailId(settlementDetails, context);
List entryGroups = new ArrayList<>();
int detailEntryCount = 0;
for (FormalSettlementDetail settlementDetail : settlementDetails) {
@@ -484,8 +501,8 @@ public class KingdeeApBillSyncSupport {
feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()),
billingUnitByRule, feeClassByRule, unitCodeByName, feeItemArchive);
}
- // 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复
- List manualSummaryFees = listSummaryFees(settlement.getId()).stream()
+ // 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复(上下文缓存按结算单id复用)
+ List manualSummaryFees = listSummaryFees(settlement.getId(), context).stream()
.filter(summaryFee -> Integer.valueOf(1).equals(summaryFee.getManualFlag())).toList();
for (FormalSettlementSummaryFee summaryFee : manualSummaryFees) {
appendSummaryEntry(entryGroups, settlement, project, summaryFee,
@@ -520,6 +537,8 @@ public class KingdeeApBillSyncSupport {
String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]";
Map groups = new LinkedHashMap<>();
boolean multiCargo = detailFees.size() > 1;
+ // 逐费用项归类明细日志去重:同一费用项同单只记一次(联调排查归类来源与缺配置项)
+ Set classifiedFeeItems = new HashSet<>();
for (FormalSettlementDetailFee detailFee : detailFees) {
Map feeItems = parseFeeItems(detailFee.getFeeItemsJson());
// 页面合计口径:费用项JSON无运费项时,运费(freightAmount)并入运输费大类参与拆分
@@ -548,6 +567,10 @@ public class KingdeeApBillSyncSupport {
BigDecimal amount = money(item.getValue());
FeeClassInfo classInfo = classifyFeeItem(feeItem, feeClassByRule, feeItemArchive);
String feeType = feeCategoryOf(feeItem, classInfo);
+ // 逐费用项归类明细(每个费用项同单只记一次):来源与税率缺失告警,提示补维护计费方案/费用项档案
+ if (classifiedFeeItems.add(feeItem)) {
+ logClassify(sourceDesc, feeItem, classInfo, feeType);
+ }
DetailSplitKey splitKey = new DetailSplitKey(cargoKey, feeType, taxRateText(classInfo.taxRate()));
DetailSplitGroup group = groups.computeIfAbsent(splitKey, key -> new DetailSplitGroup());
if (group.firstFeeItem == null) {
@@ -555,6 +578,7 @@ public class KingdeeApBillSyncSupport {
}
group.transportGoods = firstNonBlank(detailFee.getCargoName(), group.transportGoods);
group.amountTax = group.amountTax.add(amount);
+ group.feeItemAmounts.merge(feeItem, amount, BigDecimal::add);
if (isFreightFeeItem(feeItem)) {
group.freightQuantity = group.freightQuantity.add(
detailFee.getTransportQuantity() == null ? BigDecimal.ZERO : money(detailFee.getTransportQuantity()));
@@ -604,13 +628,41 @@ public class KingdeeApBillSyncSupport {
group.amountNoTax == null ? null : money(group.amountNoTax),
group.transportGoods == null ? detail.getCargoName() : group.transportGoods,
detail.getVehicleNo(), settlement.getId()));
- log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],费用大类[{}],税率[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
+ log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],费用大类[{}],税率[{}],费用项构成[{}],货物[{}],车号[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
sourceDesc, entryCode, splitKey.feeType(), Func.toStr(splitKey.taxRateText(), "未识别"),
+ feeItemDetailText(group.feeItemAmounts),
+ Func.toStr(group.transportGoods, detail.getCargoName()), detail.getVehicleNo(),
materialNumber, measureunitNumber, quantity, price, amount, group.amountNoTax == null ? null : money(group.amountNoTax));
}
return groups.size();
}
+ /**
+ * 费用项归类明细日志:记录费用项 -> 费用大类/税率及归类来源;归类来源为名称特征兜底
+ * (计费方案与费用项档案均未维护该费用项)或税率未识别时升级告警,提示补维护配置,
+ * 避免拆分行税率缺失被并入"未识别税率"行造成对账困难
+ */
+ private void logClassify(String sourceDesc, String feeItem, FeeClassInfo classInfo, String feeType) {
+ String rateText = taxRateText(classInfo.taxRate());
+ if (FALLBACK_FEE_SOURCE.equals(classInfo.source())) {
+ log.warn("金蝶分录费用归类:{}费用项[{}]未在合同计费方案规则与费用项档案中维护,仅按名称特征归类为费用大类[{}]且税率未识别,相关费用将并入未识别税率行,请补维护计费方案或费用项档案",
+ sourceDesc, feeItem, feeType);
+ } else if (classInfo.taxRate() == null) {
+ log.warn("金蝶分录费用归类:{}费用项[{}]归类费用大类[{}]({})但税率未识别,相关费用将并入未识别税率行,请补维护计费方案税率",
+ sourceDesc, feeItem, feeType, classInfo.source());
+ } else {
+ log.info("金蝶分录费用归类:{}费用项[{}] -> 费用大类[{}],税率[{}%]({})",
+ sourceDesc, feeItem, feeType, rateText, classInfo.source());
+ }
+ }
+
+ /** 费用项构成文本(联调排查对账用):费用项:金额 拼接,如"运输费:100.00 + 装卸费:50.00" */
+ private String feeItemDetailText(Map feeItemAmounts) {
+ return feeItemAmounts.entrySet().stream()
+ .map(item -> item.getKey() + ":" + money(item.getValue()))
+ .collect(Collectors.joining(" + "));
+ }
+
/**
* 结算明细整行兜底分录(历史数据:货物费用行缺失或费用项JSON为空时使用):
* 物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1),
@@ -756,6 +808,19 @@ public class KingdeeApBillSyncSupport {
return unitCodeByName.get(billingUnit.trim());
}
+ /**
+ * 合同计费方案解析(上下文缓存):按合同id解析一次计费方案JSON,得到 计费单位映射 + 费用归类映射,
+ * 批量同步全批复用,消除逐单重复解析同一合同计费方案JSON的 N+1
+ *
+ * @param contract 结算合同
+ * @param context 金蝶同步上下文(缓存载体)
+ * @return 计费方案解析结果(计费单位 + 费用归类)
+ */
+ public BillingPlanInfo billingPlanInfo(ContractManage contract, KingdeeSyncContext context) {
+ return context.billingPlanCache().computeIfAbsent(contract.getId(), contractId ->
+ new BillingPlanInfo(billingUnitByRule(contract), feeClassByRule(contract)));
+ }
+
/**
* 合同计费方案规则解析:费用类型/费用项 -> 计费单位(取首个配置了计费单位的规则,
* 计费方案 JSON 解析失败按未配置处理,由计量单位解析处统一拦截报错)
@@ -799,7 +864,7 @@ public class KingdeeApBillSyncSupport {
if (Func.isBlank(feeItem) && Func.isBlank(feeType)) {
continue;
}
- FeeClassInfo classInfo = new FeeClassInfo(feeType, feeAmount(rule.get("taxRate")));
+ FeeClassInfo classInfo = new FeeClassInfo(feeType, feeAmount(rule.get("taxRate")), PLAN_FEE_SOURCE);
if (Func.isNotBlank(feeItem)) {
mapping.putIfAbsent(feeItem, classInfo);
}
@@ -876,21 +941,21 @@ public class KingdeeApBillSyncSupport {
* @param feeItem 费用项名称
* @param feeClassByRule 合同计费方案:费用项/费用类型 -> (费用大类, 税率)
* @param feeItemArchive 费用项档案:费用项名称 -> 档案
- * @return 费用归类(费用大类 + 税率,均可为空由调用处兜底)
+ * @return 费用归类(费用大类 + 税率 + 归类来源,大类/税率可空由调用处兜底)
*/
private FeeClassInfo classifyFeeItem(String feeItem, Map feeClassByRule, Map feeItemArchive) {
FeeClassInfo classInfo = feeClassByRule.get(feeItem);
FeeItem archive = feeItemArchive.get(feeItem);
if (classInfo != null) {
if (Func.isBlank(classInfo.feeType()) && archive != null) {
- return new FeeClassInfo(textValue(archive.getFeeCategory()), classInfo.taxRate());
+ return new FeeClassInfo(textValue(archive.getFeeCategory()), classInfo.taxRate(), MIXED_FEE_SOURCE);
}
return classInfo;
}
if (archive != null) {
- return new FeeClassInfo(textValue(archive.getFeeCategory()), archive.getTaxRate());
+ return new FeeClassInfo(textValue(archive.getFeeCategory()), archive.getTaxRate(), ARCHIVE_FEE_SOURCE);
}
- return new FeeClassInfo(feeItem.contains("装卸") ? LOADING_FEE_TYPE : FREIGHT_FEE_CATEGORY, null);
+ return new FeeClassInfo(feeItem.contains("装卸") ? LOADING_FEE_TYPE : FREIGHT_FEE_CATEGORY, null, FALLBACK_FEE_SOURCE);
}
/** 费用大类归一:归类无大类时按费用项名称特征兜底(含“装卸”为装卸大类,其余归运输费大类) */
@@ -984,21 +1049,34 @@ public class KingdeeApBillSyncSupport {
return value == null ? null : String.valueOf(value).trim();
}
- /** 结算明细id -> 货物费用行(一车多货按货物拆分与费用项归类取数来源) */
- private Map> detailFeesByDetailId(List settlementDetails) {
+ /**
+ * 结算明细id -> 货物费用行(一车多货按货物拆分与费用项归类取数来源)。
+ * 上下文缓存按明细id全量占位:仅补查缓存缺失的明细id,已查过的明细(含查无费用行的)不再重复查询,
+ * 消除批量同步、多结算单单据(如收票登记分摊多结算单)下同一明细重复查询的 N+1;
+ * 返回映射保持入参明细顺序。
+ */
+ private Map> detailFeesByDetailId(List settlementDetails, KingdeeSyncContext context) {
List detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId)
.filter(Objects::nonNull).distinct().toList();
if (Func.isEmpty(detailIds)) {
return Map.of();
}
- return formalSettlementDetailFeeMapper.selectList(Wrappers.lambdaQuery()
- .in(FormalSettlementDetailFee::getFormalSettlementDetailId, detailIds)
- .eq(FormalSettlementDetailFee::getIsDeleted, 0)
- .orderByAsc(FormalSettlementDetailFee::getLineNo))
- .stream().collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId));
+ Map> cache = context.detailFeeCache();
+ List missingIds = detailIds.stream().filter(id -> !cache.containsKey(id)).toList();
+ if (Func.isNotEmpty(missingIds)) {
+ Map> loaded = formalSettlementDetailFeeMapper.selectList(Wrappers.lambdaQuery()
+ .in(FormalSettlementDetailFee::getFormalSettlementDetailId, missingIds)
+ .eq(FormalSettlementDetailFee::getIsDeleted, 0)
+ .orderByAsc(FormalSettlementDetailFee::getLineNo))
+ .stream().collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId));
+ // 查无费用行的明细也空列表占位,避免后续重复查询
+ missingIds.forEach(id -> cache.put(id, loaded.getOrDefault(id, List.of())));
+ log.info("货物费用行批量查询:明细{}行,缓存命中{}行,补查{}行", detailIds.size(), detailIds.size() - missingIds.size(), missingIds.size());
+ }
+ return detailIds.stream().collect(Collectors.toMap(id -> id, cache::get, (first, second) -> first, LinkedHashMap::new));
}
- /** 正式结算合计费用项(页面结算合计,保持行序) */
+ /** 正式结算合计费用项(页面结算合计,保持行序);无上下文兼容入口(分录解析请用 {@link #listSummaryFees(Long, KingdeeSyncContext)}) */
public List listSummaryFees(Long settlementId) {
return formalSettlementSummaryFeeMapper.selectList(Wrappers.lambdaQuery()
.eq(FormalSettlementSummaryFee::getFormalSettlementId, settlementId)
@@ -1006,6 +1084,14 @@ public class KingdeeApBillSyncSupport {
.orderByAsc(FormalSettlementSummaryFee::getLineNo));
}
+ /**
+ * 正式结算合计费用项(页面结算合计,保持行序),上下文缓存按结算单id复用:
+ * 批量同步中多张单据(如多笔收票登记)分摊同一结算单时不再重复查询(N+1)
+ */
+ public List listSummaryFees(Long settlementId, KingdeeSyncContext context) {
+ return context.summaryFeeCache().computeIfAbsent(settlementId, this::listSummaryFees);
+ }
+
/**
* 计量单位档案:单位名称 -> 计量单位编码(批量同步时全批共享一次加载结果;
* 应收单同步组件复用)
@@ -1222,13 +1308,24 @@ public class KingdeeApBillSyncSupport {
}
/**
- * 结算明细费用归类:费用大类(费用类型,如 物流配送/装卸搬运)+ 税率(百分比数值)。
- * 合同计费方案规则解析为主来源,费用项档案兜底
+ * 结算明细费用归类:费用大类(费用类型,如 物流配送/装卸搬运)+ 税率(百分比数值)+ 归类来源。
+ * 合同计费方案规则解析为主来源,费用项档案兜底,名称特征再兜底
*
* @param feeType 费用大类,未识别为空由调用处按名称特征兜底
* @param taxRate 税率(百分比数值),未识别为空
+ * @param source 归类来源(合同计费方案规则/费用项档案/名称特征兜底等,联调排查用)
*/
- private record FeeClassInfo(String feeType, BigDecimal taxRate) {
+ public record FeeClassInfo(String feeType, BigDecimal taxRate, String source) {
+ }
+
+ /**
+ * 合同计费方案解析结果(上下文缓存载体,按合同id解析一次全批复用):
+ * 计费单位映射 + 费用归类映射,供结算明细拆分与计量单位匹配取数
+ *
+ * @param billingUnitByRule 费用类型/费用项 -> 计费单位
+ * @param feeClassByRule 费用项/费用类型 -> (费用大类, 税率, 归类来源)
+ */
+ public record BillingPlanInfo(Map billingUnitByRule, Map feeClassByRule) {
}
/**
@@ -1249,6 +1346,8 @@ public class KingdeeApBillSyncSupport {
private String transportGoods;
/** 含税金额合计 */
private BigDecimal amountTax = BigDecimal.ZERO;
+ /** 组内费用项 -> 金额(联调对账明细日志用) */
+ private final Map feeItemAmounts = new LinkedHashMap<>();
/** 不含税金额分摊合计(行不含税缺失为空不送) */
private BigDecimal amountNoTax;
/** 运输量累计(运输费大类数量来源) */