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..e8b95e15 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,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 measureUnitCodeByName, Map< Map apBillNoCache, Map> settlementDetailCache, Map settlementCache, - Map receiptAccountCache, - Map feeItemCacheByName) { + Map 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<>()); } } 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..0e65fef0 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,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 成员)。

- *

明细分录口径:正式结算单页面结算明细(每行一个运单)按 费用大类×税率 拆分,一车多货再按货物拆分: - * 运输费大类费用拆一行、装卸大类费用拆一行,同大类税率不同再按税率拆多行,一车多货(明细下多行货物费用行) - * 再按货物拆分;结算合计费用项(手工添加 manualFlag=1)每行一条;无有效货物费用项的明细兜底整行一条。 + *

明细分录口径:取正式结算单页面结算明细(每行一条)+ 结算合计费用项(每行一条); * 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber}); * 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码; - * 数量运输费大类取货物运输量、其余默认 1;单价按结算金额/数量反算。

+ * 数量运费费用项目取运输量、其余默认 1;单价按结算金额/数量反算。

* * @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 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 { } /** - * 解析单张正式结算单分录:页面结算明细(每行一个运单)按 费用大类×税率 拆分,一车多货再按货物拆分; - * 结算合计费用项(手工添加)每行一条。 + * 解析单张正式结算单分录:页面结算明细每行一条 + 结算合计费用项每行一条。 *

取数口径: - * 结算明细费用行(一车多货每货物一行)费用项JSON逐项归类——合同计费方案规则定(费用大类, 税率), - * 费用项档案兜底,名称特征再兜底;运输费大类费用拆一行、装卸大类费用拆一行,同大类税率不同再拆多行; - * 物料编码按费用大类/项目业务类型/业务模式映射——装卸大类费用行映射“装卸”;国际运输业务模式 + * 物料编码按项目业务类型/业务模式/装卸大类映射——装卸大类费用行映射“装卸”;国际运输业务模式 * (或国际货物运输代理服务项目业务类型)映射“国际货物运输代理服务”;国内映射“国内道路货物运输服务-”+运单运输方式; - * 计量单位编码按费用项/费用大类匹配合同计费方案规则计费单位,再按计量单位档案取编码; - * 数量运输费大类取货物运输量合计(未维护兜底 1)、其余费用大类默认 1;单价按结算金额/数量反算; - * 结算金额(含税)按费用项金额拆分归集,不含税按行不含税×行内含税占比分摊(行不含税缺失不送); - * 无运费项的货物费用行运费(freightAmount)并入运输费大类(与页面合计口径一致); - * 运输货物/车号按拆分行货物/结算明细车号传;分录标识:未拆分取单据号,拆分行取单据号-费用大类-税率(-货物)。

+ * 计量单位编码按费用类型/费用项匹配合同计费方案规则计费单位,再按计量单位档案取编码; + * 数量运费费用项目取运输量(未维护兜底 1)、结算合计费用项默认 1;单价按结算金额/数量反算; + * 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额; + * 运输货物/车号仅结算明细行传(货物名称/车号);分录标识:结算明细行取单据号、 + * 结算合计费用项行取结算单号-费用类型-费用项拼接。

* * @param settlement 正式结算单 * @param settlementDetails 该结算单下的结算明细 @@ -469,20 +456,14 @@ 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) { - 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 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; } /** - * 单条结算明细(一个运单)拆分生成分录:货物费用行费用项按 费用大类×税率 归集,每组一条分录, - * 一车多货(明细下多行货物费用行)再按货物拆分;仅拆出一组时保持一条、分录标识取单据号。 - *

取数口径:费用项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), + * 单条结算明细行生成一条分录:物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1), * 金额取结算金额(含税),不含税取结算金额(不含税),运输货物/车号取货物名称/车号,分录标识取单据号 */ - private void appendWholeDetailEntry(List entryGroups, FormalSettlement settlement, ProjectApply project, - FormalSettlementDetail detail, List detailFees, - Map billingUnitByRule, Map unitCodeByName) { + private void appendDetailEntry(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()) @@ -738,24 +609,6 @@ 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 解析失败按未配置处理,由计量单位解析处统一拦截报错) @@ -765,203 +618,41 @@ 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> 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> 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 feeItemArchiveByName(KingdeeSyncContext context) { - Map cache = context.feeItemCacheByName(); - if (!cache.isEmpty()) { - return cache; - } - R> 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 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 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 parseFeeItems(String feeItemsJson) { - Map 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> detailFeesByDetailId(List settlementDetails) { List 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)推送结果 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java index e4b4421a..d82db7b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java @@ -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 entry = new ArrayList<>(); for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO(); @@ -307,8 +306,7 @@ public class KingdeeArBillSyncSupport { /** * 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径—— - * 页面结算明细按 费用大类×税率(一车多货再按货物)拆分 + 手工添加的结算合计费用项每行一条 - * (系统自动汇总行是明细金额的再汇总不送), + * 页面结算明细每行一条 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送), * 物料/计量单位/数量/单价/金额/分录标识取数规则一致, * 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税)) *