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> billingPlanRules(ContractManage contract) { if (Func.isBlank(contract.getBillingPlanJson())) { - return mapping; + return List.of(); } try { Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class); if (!(parsed instanceof List plans)) { - return mapping; + return List.of(); } + List> rules = new ArrayList<>(); for (Object planValue : plans) { - if (!(planValue instanceof Map plan) || !(plan.get("rules") instanceof List rules)) { + if (!(planValue instanceof Map plan) || !(plan.get("rules") instanceof List planRules)) { continue; } - 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); + for (Object ruleValue : planRules) { + if (ruleValue instanceof Map rule) { + rules.add(rule); } } } + 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 是否包含装卸大类费用(费用项名称含“装卸”) */ @@ -675,7 +984,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(); @@ -912,6 +1221,40 @@ 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 d82db7b9..e4b4421a 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,7 +144,8 @@ public class KingdeeArBillSyncSupport { * 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。 * 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时 * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 - * 明细分录与应付单(A102-2)同源同口径:页面结算明细(每行一条)+ 结算合计费用项(每行一条); + * 明细分录与应付单(A102-2)同源同口径:页面结算明细按 费用大类×税率(一车多货按货物)拆分 + * + 结算合计费用项(每行一条); * 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成 * * @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接) @@ -225,7 +226,7 @@ public class KingdeeArBillSyncSupport { param.setExchangerate(settlement.getExchangeRate()); } - // 明细分录:与应付单同源同口径——结算明细行 + 结算合计费用项各一条(不聚合),无吨位时数量兜底 1 + // 明细分录:与应付单同源同口径——结算明细按 费用大类×税率(一车多货按货物)拆分 + 结算合计费用项每行一条,无吨位时数量兜底 1 List entry = new ArrayList<>(); for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO(); @@ -306,7 +307,8 @@ public class KingdeeArBillSyncSupport { /** * 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径—— - * 页面结算明细每行一条 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送), + * 页面结算明细按 费用大类×税率(一车多货再按货物)拆分 + 手工添加的结算合计费用项每行一条 + * (系统自动汇总行是明细金额的再汇总不送), * 物料/计量单位/数量/单价/金额/分录标识取数规则一致, * 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税)) *