From 5d1c01e15eb1edccd283d49377b68800532971b0 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 22:52:07 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=91=E8=9D=B6=E5=BA=94=E4=BB=98/?=
=?UTF-8?q?=E5=BA=94=E6=94=B6=E5=8D=95=E5=88=86=E5=BD=95=E6=8C=89=E8=B4=B9?=
=?UTF-8?q?=E7=94=A8=E5=A4=A7=E7=B1=BB=C3=97=E7=A8=8E=E7=8E=87=E6=8B=86?=
=?UTF-8?q?=E5=88=86=EF=BC=8C=E4=B8=80=E8=BD=A6=E5=A4=9A=E8=B4=A7=E5=86=8D?=
=?UTF-8?q?=E6=8C=89=E8=B4=A7=E7=89=A9=E6=8B=86=E5=88=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
结算明细(一个运单)不再整行一条分录,改为按货物费用行费用项拆分归集:
- 费用大类拆分:运输费大类费用一行、装卸大类费用一行(费用类型/费用项名称归类)
- 税率拆分:同大类税率不同(合同计费方案规则税率,费用项档案兜底)再按税率拆多行
- 一车多货:明细下多行货物费用行时再按货物拆分
- 结算合计手工费用项保持每行一条;无有效货物费用项的明细兜底整行一条(历史数据)
- 含税金额按费用项归集,不含税按行内含税占比分摊(末项兜差额);运费(freightAmount)
在费用项JSON无运费项时并入运输费大类(与页面合计口径一致)
- 分录标识:未拆分保持单据号,拆分行取 单据号-费用大类-税率(-货物)保证候选键唯一
- 计量单位:费用项/费用大类匹配计费方案计费单位;非运输费大类缺配置兜底运费单位并告警
- A102-2/A302-2/A303-2 同源共用本解析;费用项档案经 ISysClient.getFeeItems 加载并入同步上下文缓存
---
.../transport/service/KingdeeSyncContext.java | 6 +-
.../support/KingdeeApBillSyncSupport.java | 433 ++++++++++++++++--
.../support/KingdeeArBillSyncSupport.java | 8 +-
3 files changed, 397 insertions(+), 50 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 e8b95e15..4a14535c 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
@@ -24,6 +24,7 @@
*/
package org.springblade.transport.service;
+import org.springblade.system.pojo.entity.FeeItem;
import org.springblade.transport.pojo.entity.ContractManage;
import org.springblade.transport.pojo.entity.CustomerReceiptAccount;
import org.springblade.transport.pojo.entity.FormalSettlement;
@@ -45,11 +46,12 @@ public record KingdeeSyncContext(Map measureUnitCodeByName, Map<
Map apBillNoCache,
Map> settlementDetailCache,
Map settlementCache,
- Map receiptAccountCache) {
+ Map receiptAccountCache,
+ Map feeItemCacheByName) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
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<>());
}
}
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 0e65fef0..e417d376 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
@@ -35,6 +35,7 @@ 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.FeeItem;
import org.springblade.system.pojo.entity.MeasurementUnit;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO;
@@ -82,10 +83,12 @@ import java.util.stream.Collectors;
* 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。
* 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件;
* 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。
- * 明细分录口径:取正式结算单页面结算明细(每行一条)+ 结算合计费用项(每行一条);
+ *
明细分录口径:正式结算单页面结算明细(每行一个运单)按 费用大类×税率 拆分,一车多货再按货物拆分:
+ * 运输费大类费用拆一行、装卸大类费用拆一行,同大类税率不同再按税率拆多行,一车多货(明细下多行货物费用行)
+ * 再按货物拆分;结算合计费用项(手工添加 manualFlag=1)每行一条;无有效货物费用项的明细兜底整行一条。
* 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber});
* 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码;
- * 数量运费费用项目取运输量、其余默认 1;单价按结算金额/数量反算。
+ * 数量运输费大类取货物运输量、其余默认 1;单价按结算金额/数量反算。
*
* @author Chill
*/
@@ -102,6 +105,12 @@ public class KingdeeApBillSyncSupport {
private static final String FREIGHT_FEE_ITEM = "运输费";
/** 结算明细行(运费)计费方案匹配费用类型 */
private static final String FREIGHT_FEE_TYPE = "物流配送";
+ /** 费用大类:装卸(费用类型/费用项名称含“装卸”) */
+ private static final String LOADING_FEE_TYPE = "装卸";
+ /** 费用大类:运输费(未识别大类时的兜底归类) */
+ private static final String FREIGHT_FEE_CATEGORY = "运输费";
+ /** 分录金额勾稽允许误差(页面金额与费用项合计的分摊尾差容忍度) */
+ private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
private final IBfiService bfiService;
private final ContractManageMapper contractManageMapper;
@@ -204,7 +213,8 @@ public class KingdeeApBillSyncSupport {
* 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。
* 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
- * 明细分录取页面结算明细(每行一条)+ 结算合计费用项(每行一条),结算金额逐行直送;
+ * 明细分录取页面结算明细按 费用大类×税率(一车多货再按货物)拆分 + 结算合计费用项(每行一条),
+ * 结算金额逐行直送;
* 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释),
* 当前由金蝶按明细自动汇总生成
*
@@ -291,7 +301,7 @@ public class KingdeeApBillSyncSupport {
// 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE);
- // 明细分录:结算明细行 + 结算合计费用项各一条(不聚合)
+ // 明细分录:结算明细按 费用大类×税率(一车多货按货物)拆分 + 结算合计费用项每行一条
List detailentry = new ArrayList<>();
for (BfiEntryGroup entryGroup : entryGroups) {
BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO();
@@ -405,8 +415,8 @@ public class KingdeeApBillSyncSupport {
// ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ====================
/**
- * 解析正式结算明细为应付单分录(不聚合):按所属正式结算单分组,逐结算单取页面结算明细
- * (每行一条分录)与结算合计费用项(每行一条分录);分录携带所属正式结算单id
+ * 解析正式结算明细为应付单分录:按所属正式结算单分组,逐结算单解析——页面结算明细按 费用大类×税率
+ * (一车多货再按货物)拆分 + 结算合计费用项(手工添加)每行一条;分录携带所属正式结算单id
* (多结算单付款申请 A103-1 下推时按结算单分组勾稽各自应付单付款计划分录)。
* 应收单(A302-2)、收票单(A303-2)分录与本解析同源同口径
*
@@ -432,15 +442,18 @@ public class KingdeeApBillSyncSupport {
}
/**
- * 解析单张正式结算单分录:页面结算明细每行一条 + 结算合计费用项每行一条。
+ * 解析单张正式结算单分录:页面结算明细(每行一个运单)按 费用大类×税率 拆分,一车多货再按货物拆分;
+ * 结算合计费用项(手工添加)每行一条。
* 取数口径:
- * 物料编码按项目业务类型/业务模式/装卸大类映射——装卸大类费用行映射“装卸”;国际运输业务模式
+ * 结算明细费用行(一车多货每货物一行)费用项JSON逐项归类——合同计费方案规则定(费用大类, 税率),
+ * 费用项档案兜底,名称特征再兜底;运输费大类费用拆一行、装卸大类费用拆一行,同大类税率不同再拆多行;
+ * 物料编码按费用大类/项目业务类型/业务模式映射——装卸大类费用行映射“装卸”;国际运输业务模式
* (或国际货物运输代理服务项目业务类型)映射“国际货物运输代理服务”;国内映射“国内道路货物运输服务-”+运单运输方式;
- * 计量单位编码按费用类型/费用项匹配合同计费方案规则计费单位,再按计量单位档案取编码;
- * 数量运费费用项目取运输量(未维护兜底 1)、结算合计费用项默认 1;单价按结算金额/数量反算;
- * 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额;
- * 运输货物/车号仅结算明细行传(货物名称/车号);分录标识:结算明细行取单据号、
- * 结算合计费用项行取结算单号-费用类型-费用项拼接。
+ * 计量单位编码按费用项/费用大类匹配合同计费方案规则计费单位,再按计量单位档案取编码;
+ * 数量运输费大类取货物运输量合计(未维护兜底 1)、其余费用大类默认 1;单价按结算金额/数量反算;
+ * 结算金额(含税)按费用项金额拆分归集,不含税按行不含税×行内含税占比分摊(行不含税缺失不送);
+ * 无运费项的货物费用行运费(freightAmount)并入运输费大类(与页面合计口径一致);
+ * 运输货物/车号按拆分行货物/结算明细车号传;分录标识:未拆分取单据号,拆分行取单据号-费用大类-税率(-货物)。
*
* @param settlement 正式结算单
* @param settlementDetails 该结算单下的结算明细
@@ -456,14 +469,20 @@ public class KingdeeApBillSyncSupport {
ContractManage contract = requireContract(settlement.getContractId(), context);
// 合同计费方案规则:费用类型/费用项 -> 计费单位
Map billingUnitByRule = billingUnitByRule(contract);
+ // 合同计费方案规则:费用项 -> (费用大类, 税率),结算明细费用项拆分归类主来源
+ Map feeClassByRule = feeClassByRule(contract);
+ // 费用项档案:费用项名称 -> 档案(费用大类/税率兜底来源)
+ Map feeItemArchive = feeItemArchiveByName(context);
// 计量单位档案:单位名称 -> 计量单位编码
Map unitCodeByName = measureUnitCodeByName(context);
- // 结算明细费用行:判断运单是否存在装卸大类费用
+ // 结算明细货物费用行:一车多货按货物拆分与费用项归类取数来源
Map> feesByDetailId = detailFeesByDetailId(settlementDetails);
List entryGroups = new ArrayList<>();
+ int detailEntryCount = 0;
for (FormalSettlementDetail settlementDetail : settlementDetails) {
- appendDetailEntry(entryGroups, settlement, project, settlementDetail,
- feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()), billingUnitByRule, unitCodeByName);
+ detailEntryCount += appendDetailEntries(entryGroups, settlement, project, settlementDetail,
+ feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()),
+ billingUnitByRule, feeClassByRule, unitCodeByName, feeItemArchive);
}
// 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复
List manualSummaryFees = listSummaryFees(settlement.getId()).stream()
@@ -476,20 +495,130 @@ public class KingdeeApBillSyncSupport {
throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo())
+ "]无结算明细与结算合计费用项,无法解析分录");
}
- // 分录解析摘要(联调排查:明细条数=结算明细行+手工添加的结算合计费用项,物料/计量单位/分录标识逐条见下方分录生成日志)
- log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录",
- settlement.getFormalSettlementNo(), settlementDetails.size(),
- entryGroups.size() - settlementDetails.size(), entryGroups.size());
+ // 分录解析摘要(联调排查:结算明细按大类/税率/货物拆分后条数+手工添加的结算合计费用项条数,物料/计量单位/分录标识逐条见下方分录生成日志)
+ log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条拆为{}条分录 + 手工结算合计{}条 = 共{}条分录",
+ settlement.getFormalSettlementNo(), settlementDetails.size(), detailEntryCount,
+ manualSummaryFees.size(), entryGroups.size());
return entryGroups;
}
/**
- * 单条结算明细行生成一条分录:物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1),
+ * 单条结算明细(一个运单)拆分生成分录:货物费用行费用项按 费用大类×税率 归集,每组一条分录,
+ * 一车多货(明细下多行货物费用行)再按货物拆分;仅拆出一组时保持一条、分录标识取单据号。
+ * 取数口径:费用项JSON逐项归类(合同计费方案规则定 费用大类×税率,费用项档案兜底、名称特征再兜底);
+ * 无运费项的货物费用行运费(freightAmount)并入运输费大类参与拆分(与页面合计口径一致);
+ * 含税金额按费用项金额归集;不含税按行不含税×行内费用项含税占比分摊(末项兜差额,行不含税缺失不送);
+ * 数量运输费大类取货物运输量合计(未维护兜底 1)、其余费用大类默认 1;单价按结算金额/数量反算;
+ * 运输货物按拆分行货物名称、车号取结算明细车号;分录标识未拆分取单据号、拆分行取单据号-费用大类-税率(-货物)。
+ *
+ * @return 该明细生成的分录条数
+ */
+ private int appendDetailEntries(List entryGroups, FormalSettlement settlement, ProjectApply project,
+ FormalSettlementDetail detail, List detailFees,
+ Map billingUnitByRule, Map feeClassByRule,
+ Map unitCodeByName, Map feeItemArchive) {
+ String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]";
+ Map groups = new LinkedHashMap<>();
+ boolean multiCargo = detailFees.size() > 1;
+ for (FormalSettlementDetailFee detailFee : detailFees) {
+ Map feeItems = parseFeeItems(detailFee.getFeeItemsJson());
+ // 页面合计口径:费用项JSON无运费项时,运费(freightAmount)并入运输费大类参与拆分
+ if (feeItems.keySet().stream().noneMatch(this::isFreightFeeItem)
+ && money(detailFee.getFreightAmount()).compareTo(BigDecimal.ZERO) > 0) {
+ feeItems.put(FREIGHT_FEE_ITEM, money(detailFee.getFreightAmount()));
+ }
+ if (feeItems.isEmpty()) {
+ continue;
+ }
+ BigDecimal rowItemTotal = feeItems.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+ if (money(detailFee.getSettlementAmountTax()).subtract(rowItemTotal).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
+ log.warn("金蝶应付单分录拆分:{}货物费用行[{}]结算金额(含税)[{}]与费用项合计[{}]不一致,按费用项拆分归集,请核对页面费用项维护",
+ sourceDesc, Func.toStr(detailFee.getCargoName(), detailFee.getLineNo()),
+ money(detailFee.getSettlementAmountTax()), rowItemTotal);
+ }
+ // 行内不含税按各费用项含税占比分摊,末项兜差额,保证行合计不因四舍五入漂移
+ BigDecimal rowAmountNoTax = detailFee.getSettlementAmountNoTax() == null ? null : money(detailFee.getSettlementAmountNoTax());
+ BigDecimal allocatedNoTax = BigDecimal.ZERO;
+ // 一车多货按货物拆分(货物名称缺失兜底行号);单车货物不按货物拆分
+ String cargoKey = multiCargo ? firstNonBlank(detailFee.getCargoName(), detailFee.getLineNo(), "货物") : null;
+ List> itemList = new ArrayList<>(feeItems.entrySet());
+ for (int index = 0; index < itemList.size(); index++) {
+ Map.Entry item = itemList.get(index);
+ String feeItem = item.getKey();
+ BigDecimal amount = money(item.getValue());
+ FeeClassInfo classInfo = classifyFeeItem(feeItem, feeClassByRule, feeItemArchive);
+ String feeType = feeCategoryOf(feeItem, classInfo);
+ DetailSplitKey splitKey = new DetailSplitKey(cargoKey, feeType, taxRateText(classInfo.taxRate()));
+ DetailSplitGroup group = groups.computeIfAbsent(splitKey, key -> new DetailSplitGroup());
+ if (group.firstFeeItem == null) {
+ group.firstFeeItem = feeItem;
+ }
+ group.transportGoods = firstNonBlank(detailFee.getCargoName(), group.transportGoods);
+ group.amountTax = group.amountTax.add(amount);
+ if (isFreightFeeItem(feeItem)) {
+ group.freightQuantity = group.freightQuantity.add(
+ detailFee.getTransportQuantity() == null ? BigDecimal.ZERO : money(detailFee.getTransportQuantity()));
+ }
+ if (rowAmountNoTax != null) {
+ BigDecimal share = index == itemList.size() - 1
+ ? rowAmountNoTax.subtract(allocatedNoTax)
+ : rowAmountNoTax.multiply(amount).divide(rowItemTotal, 2, RoundingMode.HALF_UP);
+ allocatedNoTax = allocatedNoTax.add(share);
+ group.amountNoTax = (group.amountNoTax == null ? BigDecimal.ZERO : group.amountNoTax).add(share);
+ }
+ }
+ }
+ if (groups.isEmpty()) {
+ // 历史数据兜底:货物费用行缺失或费用项JSON为空时按明细整行生成分录,避免整单无法推送
+ log.warn("金蝶应付单分录拆分:{}无有效货物费用项(货物费用行缺失或费用项JSON为空),按明细整行兜底生成分录", sourceDesc);
+ appendWholeDetailEntry(entryGroups, settlement, project, detail, detailFees, billingUnitByRule, unitCodeByName);
+ return 1;
+ }
+ // 分录唯一标识前缀(awrp_entry_code):结算明细单据号;未维护时兜底明细行id,避免候选键为空被金蝶拦截
+ String baseEntryCode = Func.isBlank(detail.getDocumentNo()) ? String.valueOf(detail.getId()) : detail.getDocumentNo().trim();
+ boolean singleGroup = groups.size() == 1;
+ for (Map.Entry splitEntry : groups.entrySet()) {
+ DetailSplitKey splitKey = splitEntry.getKey();
+ DetailSplitGroup group = splitEntry.getValue();
+ boolean loadingFee = splitKey.feeType().contains("装卸");
+ String materialNumber = resolveMaterialNumber(loadingFee, project, detail.getTransportType(), sourceDesc);
+ // 计量单位编码:费用项优先、费用大类兜底匹配合同计费方案计费单位,再匹配计量单位档案
+ String measureunitNumber = findMeasureunitNumber(billingUnitByRule, unitCodeByName, group.firstFeeItem, splitKey.feeType());
+ if (Func.isBlank(measureunitNumber)) {
+ if (isFreightFeeItem(group.firstFeeItem) || FREIGHT_FEE_TYPE.equals(splitKey.feeType())) {
+ // 运输费大类主行计量单位缺失直接拦截(与原整行口径一致)
+ throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在合同计费方案中配置计费单位,请先维护计费方案规则");
+ }
+ // 其他费用大类未配置计费单位时兜底运费计费单位(数量默认 1,单位仅作展示口径),避免历史合同缺配置阻塞推送
+ log.warn("金蝶应付单分录拆分:{}费用大类[{}](费用项[{}])未配置计费单位,兜底运费计费单位", sourceDesc, splitKey.feeType(), group.firstFeeItem);
+ measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, FREIGHT_FEE_ITEM, FREIGHT_FEE_TYPE, sourceDesc);
+ }
+ // 数量:运输费大类取货物运输量合计,其余费用大类默认 1
+ BigDecimal quantity = group.freightQuantity.compareTo(BigDecimal.ZERO) > 0 ? group.freightQuantity : BigDecimal.ONE;
+ BigDecimal amount = money(group.amountTax);
+ BigDecimal price = entryPrice(amount, quantity);
+ String entryCode = singleGroup ? baseEntryCode
+ : baseEntryCode + "-" + splitKey.feeType() + "-" + Func.toStr(splitKey.taxRateText(), "其他")
+ + (splitKey.cargoName() == null ? "" : "-" + splitKey.cargoName());
+ entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, quantity, price, amount,
+ group.amountNoTax == null ? null : money(group.amountNoTax),
+ group.transportGoods == null ? detail.getCargoName() : group.transportGoods,
+ detail.getVehicleNo(), settlement.getId()));
+ log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],费用大类[{}],税率[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
+ sourceDesc, entryCode, splitKey.feeType(), Func.toStr(splitKey.taxRateText(), "未识别"),
+ materialNumber, measureunitNumber, quantity, price, amount, group.amountNoTax == null ? null : money(group.amountNoTax));
+ }
+ return groups.size();
+ }
+
+ /**
+ * 结算明细整行兜底分录(历史数据:货物费用行缺失或费用项JSON为空时使用):
+ * 物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1),
* 金额取结算金额(含税),不含税取结算金额(不含税),运输货物/车号取货物名称/车号,分录标识取单据号
*/
- private void appendDetailEntry(List entryGroups, FormalSettlement settlement, ProjectApply project,
- FormalSettlementDetail detail, List detailFees,
- Map billingUnitByRule, Map unitCodeByName) {
+ private void appendWholeDetailEntry(List entryGroups, FormalSettlement settlement, ProjectApply project,
+ FormalSettlementDetail detail, List detailFees,
+ Map billingUnitByRule, Map unitCodeByName) {
String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]";
// 该条运单存在装卸大类费用(明细费用项或明细费用行费用项含“装卸”)时物料映射为装卸
boolean loadingFee = hasLoadingFeeItem(detail.getFeeItemsJson())
@@ -609,6 +738,24 @@ public class KingdeeApBillSyncSupport {
return unitCode;
}
+ /**
+ * 计量单位编码查找(不拦截):费用项优先、费用类型兜底匹配合同计费方案规则计费单位,
+ * 再按计量单位档案(单位名称匹配)取计量单位编码,供结算明细拆分行按费用大类兜底取数
+ *
+ * @return 计量单位编码,任一环节缺失返回 null
+ */
+ private String findMeasureunitNumber(Map billingUnitByRule, Map unitCodeByName,
+ String feeItem, String feeType) {
+ String billingUnit = Func.isBlank(feeItem) ? null : billingUnitByRule.get(feeItem.trim());
+ if (Func.isBlank(billingUnit) && Func.isNotBlank(feeType)) {
+ billingUnit = billingUnitByRule.get(feeType.trim());
+ }
+ if (Func.isBlank(billingUnit)) {
+ return null;
+ }
+ return unitCodeByName.get(billingUnit.trim());
+ }
+
/**
* 合同计费方案规则解析:费用类型/费用项 -> 计费单位(取首个配置了计费单位的规则,
* 计费方案 JSON 解析失败按未配置处理,由计量单位解析处统一拦截报错)
@@ -618,41 +765,203 @@ public class KingdeeApBillSyncSupport {
*/
private Map billingUnitByRule(ContractManage contract) {
Map mapping = new HashMap<>();
+ for (Map, ?> rule : billingPlanRules(contract)) {
+ String billingUnit = textValue(rule.get("billingUnit"));
+ if (Func.isBlank(billingUnit)) {
+ continue;
+ }
+ String feeItem = textValue(rule.get("feeItem"));
+ String feeType = textValue(rule.get("feeType"));
+ if (Func.isNotBlank(feeItem)) {
+ mapping.putIfAbsent(feeItem, billingUnit);
+ }
+ if (Func.isNotBlank(feeType)) {
+ mapping.putIfAbsent(feeType, billingUnit);
+ }
+ }
+ log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置计费单位{}", contract.getContractNo(), mapping.size(), mapping);
+ return mapping;
+ }
+
+ /**
+ * 合同计费方案规则费用归类解析:费用项/费用类型 -> (费用大类, 税率)。
+ * 规则同时按费用项、费用类型两个维度登记(先登记优先,与计费单位解析同口径),
+ * 供结算明细费用项按 费用大类×税率 拆分归类取数
+ *
+ * @param contract 结算合同
+ * @return 费用项/费用类型 -> 费用归类映射
+ */
+ private Map feeClassByRule(ContractManage contract) {
+ Map mapping = new HashMap<>();
+ for (Map, ?> rule : billingPlanRules(contract)) {
+ String feeItem = textValue(rule.get("feeItem"));
+ String feeType = textValue(rule.get("feeType"));
+ if (Func.isBlank(feeItem) && Func.isBlank(feeType)) {
+ continue;
+ }
+ FeeClassInfo classInfo = new FeeClassInfo(feeType, feeAmount(rule.get("taxRate")));
+ if (Func.isNotBlank(feeItem)) {
+ mapping.putIfAbsent(feeItem, classInfo);
+ }
+ if (Func.isNotBlank(feeType)) {
+ mapping.putIfAbsent(feeType, classInfo);
+ }
+ }
+ log.info("合同[{}]计费方案费用归类解析:{}项费用项/费用类型(费用大类, 税率){}", contract.getContractNo(), mapping.size(), mapping);
+ return mapping;
+ }
+
+ /**
+ * 合同计费方案规则平铺:解析计费方案JSON全部方案下的规则(保持配置顺序;
+ * 计费方案 JSON 兼容历史数据,解析失败按未配置处理,由取用处统一拦截/兜底)
+ *
+ * @param contract 结算合同
+ * @return 规则列表(可能为空)
+ */
+ private List