Revert "feat: 金蝶应付/应收单分录按费用大类×税率拆分,一车多货再按货物拆分"

This reverts commit 5d1c01e15e.
This commit is contained in:
刘泉佳
2026-10-08 18:39:57 +08:00
parent 638a38a467
commit 36c264bfab
3 changed files with 50 additions and 397 deletions
@@ -24,7 +24,6 @@
*/
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;
@@ -46,12 +45,11 @@ public record KingdeeSyncContext(Map<String, String> measureUnitCodeByName, Map<
Map<String, String> apBillNoCache,
Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
Map<Long, FormalSettlement> settlementCache,
Map<Long, CustomerReceiptAccount> receiptAccountCache,
Map<String, FeeItem> feeItemCacheByName) {
Map<Long, CustomerReceiptAccount> receiptAccountCache) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
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<>());
}
}
@@ -35,7 +35,6 @@ 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;
@@ -83,12 +82,10 @@ import java.util.stream.Collectors;
* 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。
* 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件;
* 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。</p>
* <p>明细分录口径:正式结算单页面结算明细(每行一个运单)按 费用大类×税率 拆分,一车多货再按货物拆分:
* 运输费大类费用拆一行、装卸大类费用拆一行,同大类税率不同再按税率拆多行,一车多货(明细下多行货物费用行)
* 再按货物拆分;结算合计费用项(手工添加 manualFlag=1)每行一条;无有效货物费用项的明细兜底整行一条。
* <p>明细分录口径:取正式结算单页面结算明细(每行一条)+ 结算合计费用项(每行一条);
* 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber});
* 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码;
* 数量运输费大类取货物运输量、其余默认 1;单价按结算金额/数量反算。</p>
* 数量运费费用项目取运输量、其余默认 1;单价按结算金额/数量反算。</p>
*
* @author Chill
*/
@@ -105,12 +102,6 @@ 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;
@@ -213,8 +204,7 @@ public class KingdeeApBillSyncSupport {
* 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。
* 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录取页面结算明细按 费用大类×税率(一车多货再按货物)拆分 + 结算合计费用项(每行一条),
* 结算金额逐行直送;
* 明细分录取页面结算明细(每行一条)+ 结算合计费用项(每行一条),结算金额逐行直送;
* 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释),
* 当前由金蝶按明细自动汇总生成
*
@@ -301,7 +291,7 @@ public class KingdeeApBillSyncSupport {
// 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE);
// 明细分录:结算明细按 费用大类×税率(一车多货按货物)拆分 + 结算合计费用项每行一条
// 明细分录:结算明细行 + 结算合计费用项各一条(不聚合)
List<BfiApBillSaveEntryDTO> detailentry = new ArrayList<>();
for (BfiEntryGroup entryGroup : entryGroups) {
BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO();
@@ -415,8 +405,8 @@ public class KingdeeApBillSyncSupport {
// ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ====================
/**
* 解析正式结算明细为应付单分录:按所属正式结算单分组,逐结算单解析——页面结算明细按 费用大类×税率
* (一车多货再按货物)拆分 + 结算合计费用项(手工添加)每行一条;分录携带所属正式结算单id
* 解析正式结算明细为应付单分录(不聚合):按所属正式结算单分组,逐结算单取页面结算明细
* (每行一条分录)与结算合计费用项(每行一条分录);分录携带所属正式结算单id
* (多结算单付款申请 A103-1 下推时按结算单分组勾稽各自应付单付款计划分录)。
* 应收单(A302-2)、收票单(A303-2)分录与本解析同源同口径
*
@@ -442,18 +432,15 @@ public class KingdeeApBillSyncSupport {
}
/**
* 解析单张正式结算单分录:页面结算明细(每行一个运单)按 费用大类×税率 拆分,一车多货再按货物拆分;
* 结算合计费用项(手工添加)每行一条。
* 解析单张正式结算单分录:页面结算明细每行一条 + 结算合计费用项每行一条。
* <p>取数口径:
* 结算明细费用行(一车多货每货物一行)费用项JSON逐项归类——合同计费方案规则定(费用大类, 税率),
* 费用项档案兜底,名称特征再兜底;运输费大类费用拆一行、装卸大类费用拆一行,同大类税率不同再拆多行;
* 物料编码按费用大类/项目业务类型/业务模式映射——装卸大类费用行映射“装卸”;国际运输业务模式
* 物料编码按项目业务类型/业务模式/装卸大类映射——装卸大类费用行映射“装卸”;国际运输业务模式
* (或国际货物运输代理服务项目业务类型)映射“国际货物运输代理服务”;国内映射“国内道路货物运输服务-”+运单运输方式;
* 计量单位编码按费用项/费用大类匹配合同计费方案规则计费单位,再按计量单位档案取编码;
* 数量运输费大类取货物运输量合计(未维护兜底 1)、其余费用大类默认 1;单价按结算金额/数量反算;
* 结算金额(含税)按费用项金额拆分归集,不含税按行不含税×行内含税占比分摊(行不含税缺失不送);
* 无运费项的货物费用行运费(freightAmount)并入运输费大类(与页面合计口径一致);
* 运输货物/车号按拆分行货物/结算明细车号传;分录标识:未拆分取单据号,拆分行取单据号-费用大类-税率(-货物)。</p>
* 计量单位编码按费用类型/费用项匹配合同计费方案规则计费单位,再按计量单位档案取编码;
* 数量运费费用项目取运输量(未维护兜底 1)、结算合计费用项默认 1;单价按结算金额/数量反算;
* 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额;
* 运输货物/车号仅结算明细行传(货物名称/车号);分录标识:结算明细行取单据号、
* 结算合计费用项行取结算单号-费用类型-费用项拼接。</p>
*
* @param settlement 正式结算单
* @param settlementDetails 该结算单下的结算明细
@@ -469,20 +456,14 @@ public class KingdeeApBillSyncSupport {
ContractManage contract = requireContract(settlement.getContractId(), context);
// 合同计费方案规则:费用类型/费用项 -> 计费单位
Map<String, String> billingUnitByRule = billingUnitByRule(contract);
// 合同计费方案规则:费用项 -> (费用大类, 税率),结算明细费用项拆分归类主来源
Map<String, FeeClassInfo> feeClassByRule = feeClassByRule(contract);
// 费用项档案:费用项名称 -> 档案(费用大类/税率兜底来源)
Map<String, FeeItem> feeItemArchive = feeItemArchiveByName(context);
// 计量单位档案:单位名称 -> 计量单位编码
Map<String, String> unitCodeByName = measureUnitCodeByName(context);
// 结算明细货物费用行:一车多货按货物拆分与费用项归类取数来源
// 结算明细费用行:判断运单是否存在装卸大类费用
Map<Long, List<FormalSettlementDetailFee>> feesByDetailId = detailFeesByDetailId(settlementDetails);
List<BfiEntryGroup> entryGroups = new ArrayList<>();
int detailEntryCount = 0;
for (FormalSettlementDetail settlementDetail : settlementDetails) {
detailEntryCount += appendDetailEntries(entryGroups, settlement, project, settlementDetail,
feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()),
billingUnitByRule, feeClassByRule, unitCodeByName, feeItemArchive);
appendDetailEntry(entryGroups, settlement, project, settlementDetail,
feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()), billingUnitByRule, unitCodeByName);
}
// 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复
List<FormalSettlementSummaryFee> manualSummaryFees = listSummaryFees(settlement.getId()).stream()
@@ -495,130 +476,20 @@ public class KingdeeApBillSyncSupport {
throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo())
+ "]无结算明细与结算合计费用项,无法解析分录");
}
// 分录解析摘要(联调排查:结算明细按大类/税率/货物拆分后条数+手工添加的结算合计费用项条数,物料/计量单位/分录标识逐条见下方分录生成日志)
log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条拆为{}条分录 + 手工结算合计{}条 = 共{}条分录",
settlement.getFormalSettlementNo(), settlementDetails.size(), detailEntryCount,
manualSummaryFees.size(), entryGroups.size());
// 分录解析摘要(联调排查:明细条数=结算明细行+手工添加的结算合计费用项,物料/计量单位/分录标识逐条见下方分录生成日志)
log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录",
settlement.getFormalSettlementNo(), settlementDetails.size(),
entryGroups.size() - settlementDetails.size(), entryGroups.size());
return entryGroups;
}
/**
* 单条结算明细(一个运单)拆分生成分录:货物费用行费用项按 费用大类×税率 归集,每组一条分录,
* 一车多货(明细下多行货物费用行)再按货物拆分;仅拆出一组时保持一条、分录标识取单据号。
* <p>取数口径:费用项JSON逐项归类(合同计费方案规则定 费用大类×税率,费用项档案兜底、名称特征再兜底);
* 无运费项的货物费用行运费(freightAmount)并入运输费大类参与拆分(与页面合计口径一致);
* 含税金额按费用项金额归集;不含税按行不含税×行内费用项含税占比分摊(末项兜差额,行不含税缺失不送);
* 数量运输费大类取货物运输量合计(未维护兜底 1)、其余费用大类默认 1;单价按结算金额/数量反算;
* 运输货物按拆分行货物名称、车号取结算明细车号;分录标识未拆分取单据号、拆分行取单据号-费用大类-税率(-货物)。</p>
*
* @return 该明细生成的分录条数
*/
private int appendDetailEntries(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project,
FormalSettlementDetail detail, List<FormalSettlementDetailFee> detailFees,
Map<String, String> billingUnitByRule, Map<String, FeeClassInfo> feeClassByRule,
Map<String, String> unitCodeByName, Map<String, FeeItem> feeItemArchive) {
String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]";
Map<DetailSplitKey, DetailSplitGroup> groups = new LinkedHashMap<>();
boolean multiCargo = detailFees.size() > 1;
for (FormalSettlementDetailFee detailFee : detailFees) {
Map<String, BigDecimal> 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<Map.Entry<String, BigDecimal>> itemList = new ArrayList<>(feeItems.entrySet());
for (int index = 0; index < itemList.size(); index++) {
Map.Entry<String, BigDecimal> 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<DetailSplitKey, DetailSplitGroup> 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),
* 单条结算明细行生成一条分录:物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1),
* 金额取结算金额(含税),不含税取结算金额(不含税),运输货物/车号取货物名称/车号,分录标识取单据号
*/
private void appendWholeDetailEntry(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project,
FormalSettlementDetail detail, List<FormalSettlementDetailFee> detailFees,
Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName) {
private void appendDetailEntry(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project,
FormalSettlementDetail detail, List<FormalSettlementDetailFee> detailFees,
Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName) {
String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]";
// 该条运单存在装卸大类费用(明细费用项或明细费用行费用项含“装卸”)时物料映射为装卸
boolean loadingFee = hasLoadingFeeItem(detail.getFeeItemsJson())
@@ -738,24 +609,6 @@ public class KingdeeApBillSyncSupport {
return unitCode;
}
/**
* 计量单位编码查找(不拦截):费用项优先、费用类型兜底匹配合同计费方案规则计费单位,
* 再按计量单位档案(单位名称匹配)取计量单位编码,供结算明细拆分行按费用大类兜底取数
*
* @return 计量单位编码,任一环节缺失返回 null
*/
private String findMeasureunitNumber(Map<String, String> billingUnitByRule, Map<String, String> 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 解析失败按未配置处理,由计量单位解析处统一拦截报错)
@@ -765,203 +618,41 @@ public class KingdeeApBillSyncSupport {
*/
private Map<String, String> billingUnitByRule(ContractManage contract) {
Map<String, String> 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<String, FeeClassInfo> feeClassByRule(ContractManage contract) {
Map<String, FeeClassInfo> 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<Map<?, ?>> billingPlanRules(ContractManage contract) {
if (Func.isBlank(contract.getBillingPlanJson())) {
return List.of();
return mapping;
}
try {
Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class);
if (!(parsed instanceof List<?> plans)) {
return List.of();
return mapping;
}
List<Map<?, ?>> rules = new ArrayList<>();
for (Object planValue : plans) {
if (!(planValue instanceof Map<?, ?> plan) || !(plan.get("rules") instanceof List<?> planRules)) {
if (!(planValue instanceof Map<?, ?> plan) || !(plan.get("rules") instanceof List<?> rules)) {
continue;
}
for (Object ruleValue : planRules) {
if (ruleValue instanceof Map<?, ?> rule) {
rules.add(rule);
for (Object ruleValue : rules) {
if (!(ruleValue instanceof Map<?, ?> rule)) {
continue;
}
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);
}
}
}
return rules;
} catch (RuntimeException ignored) {
// 计费方案 JSON 兼容历史数据,解析失败按未配置处理
return List.of();
}
}
/**
* 费用项档案:费用项名称 -> 档案(费用大类/税率兜底来源;批量同步时全批共享一次加载结果)。
* 档案读取失败仅告警不拦截:归类主来源为合同计费方案,档案仅兜底
*/
public Map<String, FeeItem> feeItemArchiveByName(KingdeeSyncContext context) {
Map<String, FeeItem> cache = context.feeItemCacheByName();
if (!cache.isEmpty()) {
return cache;
}
R<List<FeeItem>> response = sysClient.getFeeItems();
if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) {
log.warn("费用项档案读取失败,结算明细费用拆分归类仅依赖合同计费方案与名称特征兜底");
return cache;
}
for (FeeItem feeItem : response.getData()) {
if (feeItem == null || Func.isBlank(feeItem.getName())) {
continue;
}
cache.putIfAbsent(feeItem.getName().trim(), feeItem);
}
log.info("费用项档案加载:{}项(费用项 -> 费用大类/税率)", cache.size());
return cache;
}
/**
* 结算明细费用项归类:合同计费方案规则(费用项维度)优先,缺大类/税率时以费用项档案补齐,
* 档案亦未维护时按名称特征兜底(含“装卸”为装卸大类,其余归运输费大类,税率未知)
*
* @param feeItem 费用项名称
* @param feeClassByRule 合同计费方案:费用项/费用类型 -> (费用大类, 税率)
* @param feeItemArchive 费用项档案:费用项名称 -> 档案
* @return 费用归类(费用大类 + 税率,均可为空由调用处兜底)
*/
private FeeClassInfo classifyFeeItem(String feeItem, Map<String, FeeClassInfo> feeClassByRule, Map<String, FeeItem> 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 classInfo;
}
if (archive != null) {
return new FeeClassInfo(textValue(archive.getFeeCategory()), archive.getTaxRate());
}
return new FeeClassInfo(feeItem.contains("装卸") ? LOADING_FEE_TYPE : FREIGHT_FEE_CATEGORY, null);
}
/** 费用大类归一:归类无大类时按费用项名称特征兜底(含“装卸”为装卸大类,其余归运输费大类) */
private String feeCategoryOf(String feeItem, FeeClassInfo classInfo) {
if (Func.isNotBlank(classInfo.feeType())) {
return classInfo.feeType().trim();
}
return feeItem.contains("装卸") ? LOADING_FEE_TYPE : FREIGHT_FEE_CATEGORY;
}
/** 运费类费用项名称判定:名称含“运费/运输费”(与页面合计口径一致) */
private boolean isFreightFeeItem(String name) {
return name != null && (name.contains("运费") || name.contains("运输费"));
}
/** 税率文本:去尾零(9.00 -> 9,12.50 -> 12.5),未识别返回 null */
private String taxRateText(BigDecimal taxRate) {
if (taxRate == null) {
return null;
}
return taxRate.stripTrailingZeros().toPlainString();
}
/** 首个非空白文本(去空白),全空返回 null */
private String firstNonBlank(String... values) {
for (String value : values) {
if (Func.isNotBlank(value)) {
return value.trim();
}
}
return null;
}
/** 费用项JSON解析:费用项 -> 金额(保持键序;空/坏JSON兼容为空映射) */
private Map<String, BigDecimal> parseFeeItems(String feeItemsJson) {
Map<String, BigDecimal> items = new LinkedHashMap<>();
if (Func.isEmpty(feeItemsJson)) {
return items;
}
try {
Object parsed = JsonUtil.parse(feeItemsJson, Map.class);
if (parsed instanceof Map<?, ?> map) {
map.forEach((key, value) -> {
BigDecimal amount = feeAmount(value);
if (key != null && amount != null) {
items.put(String.valueOf(key).trim(), amount);
}
});
}
} catch (RuntimeException ignored) {
// 兼容历史费用JSON
}
return items;
}
/** 费用项金额解析(数值/文本兼容,无法解析返回 null) */
private BigDecimal feeAmount(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigDecimal bigDecimal) {
return bigDecimal;
}
if (value instanceof Number number) {
return BigDecimal.valueOf(number.doubleValue());
}
try {
return new BigDecimal(String.valueOf(value).trim());
} catch (NumberFormatException ignored) {
return null;
}
log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置计费单位{}", contract.getContractNo(), mapping.size(), mapping);
return mapping;
}
/** 费用项 JSON 是否包含装卸大类费用(费用项名称含“装卸”) */
@@ -984,7 +675,7 @@ public class KingdeeApBillSyncSupport {
return value == null ? null : String.valueOf(value).trim();
}
/** 结算明细id -> 货物费用行(一车多货按货物拆分与费用项归类取数来源) */
/** 结算明细id -> 结算明细费用行(判断运单是否存在装卸大类费用) */
private Map<Long, List<FormalSettlementDetailFee>> detailFeesByDetailId(List<FormalSettlementDetail> settlementDetails) {
List<Long> detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId)
.filter(Objects::nonNull).distinct().toList();
@@ -1221,40 +912,6 @@ public class KingdeeApBillSyncSupport {
String transportGoods, String vehicleNo, Long formalSettlementId) {
}
/**
* 结算明细费用归类:费用大类(费用类型,如 物流配送/装卸搬运)+ 税率(百分比数值)。
* 合同计费方案规则解析为主来源,费用项档案兜底
*
* @param feeType 费用大类,未识别为空由调用处按名称特征兜底
* @param taxRate 税率(百分比数值),未识别为空
*/
private record FeeClassInfo(String feeType, BigDecimal taxRate) {
}
/**
* 结算明细分录拆分键:货物(一车多货拆分维,非一车多货为空)+ 费用大类 + 税率文本,每个键生成一条分录
*
* @param cargoName 货物名称(非一车多货为空,不分货物)
* @param feeType 费用大类(如 物流配送/装卸搬运)
* @param taxRateText 税率文本(去尾零,如 9/6/13;未识别为空)
*/
private record DetailSplitKey(String cargoName, String feeType, String taxRateText) {
}
/** 结算明细分录拆分归集:同键(货物×费用大类×税率)费用项金额/不含税分摊/运输量累计 */
private static final class DetailSplitGroup {
/** 组内首个费用项(计量单位匹配用) */
private String firstFeeItem;
/** 运输货物展示(组内首个有效货物名称,空则发送结算明细货物名称) */
private String transportGoods;
/** 含税金额合计 */
private BigDecimal amountTax = BigDecimal.ZERO;
/** 不含税金额分摊合计(行不含税缺失为空不送) */
private BigDecimal amountNoTax;
/** 运输量累计(运输费大类数量来源) */
private BigDecimal freightQuantity = BigDecimal.ZERO;
}
/**
* 财务应付单(A102-2)推送结果
*
@@ -144,8 +144,7 @@ public class KingdeeArBillSyncSupport {
* 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。
* 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录与应付单(A102-2)同源同口径:页面结算明细按 费用大类×税率(一车多货按货物)拆分
* + 结算合计费用项(每行一条);
* 明细分录与应付单(A102-2)同源同口径:页面结算明细(每行一条)+ 结算合计费用项(每行一条);
* 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成
*
* @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接)
@@ -226,7 +225,7 @@ public class KingdeeArBillSyncSupport {
param.setExchangerate(settlement.getExchangeRate());
}
// 明细分录:与应付单同源同口径——结算明细按 费用大类×税率(一车多货按货物)拆分 + 结算合计费用项每行一条,无吨位时数量兜底 1
// 明细分录:与应付单同源同口径——结算明细行 + 结算合计费用项各一条(不聚合),无吨位时数量兜底 1
List<BfiArBillSaveEntryDTO> entry = new ArrayList<>();
for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) {
BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO();
@@ -307,8 +306,7 @@ public class KingdeeArBillSyncSupport {
/**
* 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径——
* 页面结算明细按 费用大类×税率(一车多货再按货物)拆分 + 手工添加的结算合计费用项每行一条
* (系统自动汇总行是明细金额的再汇总不送),
* 页面结算明细每行一条 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送),
* 物料/计量单位/数量/单价/金额/分录标识取数规则一致,
* 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税))
*