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; /** 运输量累计(运输费大类数量来源) */