From 04d325864925417684bb8d7ef7060b73793e1f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 20:29:16 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E5=AE=A2=E5=95=86=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=87=91=E8=9D=B6=E5=B7=B2=E5=AD=98=E5=9C=A8=E6=97=B6?= =?UTF-8?q?=E5=AE=A2=E5=95=86=E7=BC=96=E5=8F=B7=E9=87=87=E7=BA=B3=E9=87=91?= =?UTF-8?q?=E8=9D=B6=E5=AE=A2=E5=95=86=E7=BC=96=E7=A0=81=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/CustomerArchiveServiceImpl.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index a8444ebf..ab5f19fb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -515,10 +515,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpllambdaQuery() + .eq(CustomerArchive::getCustomerCode, kingdeeNumber) + .ne(CustomerArchive::getId, customer.getId())); + if (occupied > 0) { + log.warn("金蝶客商编码 {} 已被其他客商占用,跳过采纳客商编号,id={},customerCode={}", + kingdeeNumber, customer.getId(), customer.getCustomerCode()); + return ",金蝶客商编码 " + kingdeeNumber + " 已被占用未同步"; + } + after.setCustomerCode(kingdeeNumber); + return ",客商编号已同步为金蝶客商编码:" + kingdeeNumber; + } + private BfiBillSaveResultItemVO firstSaveItem(BfiBillSaveResultVO result) { if (result == null || result.getResult() == null || result.getResult().isEmpty()) { return null; @@ -739,11 +764,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl Date: Mon, 19 Oct 2026 21:38:23 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=E9=87=91=E8=9D=B6=E5=88=86=E5=BD=95?= =?UTF-8?q?=E8=B4=B9=E7=94=A8=E5=A4=A7=E7=B1=BB=E6=94=B9=E6=8C=89=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E7=BC=96=E7=A0=81=E7=B2=BE=E7=A1=AE=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 费用大类判定弃用字典名称文本匹配,改为档案 blade_fee_item.fee_category 编码比对:运输 A01 / 装卸 A10(写死常量) - 修复 SJS2026101900004 车辆维修费(A01)因字典值「运输」未命中「运费/运输费」关键字被误映射装卸物料的问题 - 档案未维护时回退行费用类型编码与名称关键字判定;金蝶应收/应付分录解析共用,一处修复两侧生效 - 补充分录拆分回归用例(档案编码优先于名称关键字 + fallback 编码判定) --- .../support/KingdeeApBillSyncSupport.java | 55 ++++++++++--------- .../transport/KingdeeEntrySplitTest.java | 23 +++++++- 2 files changed, 49 insertions(+), 29 deletions(-) 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 f2d12f48..db94c665 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 @@ -32,11 +32,9 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.api.R; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.DictBizCache; 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.DictBiz; import org.springblade.system.pojo.entity.FeeItem; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; @@ -102,8 +100,10 @@ public class KingdeeApBillSyncSupport { private static final int PAY_RESULT_PAGE_SIZE = 200; /** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */ private static final String FREIGHT_FEE_ITEM = "运输费"; - /** 业务字典:费用类型(fee_category),费用项档案分类编码 -> 名称 */ - private static final String FEE_CATEGORY_DICT = "fee_category"; + /** 费用大类编码(业务字典 fee_category 键值):运输 */ + public static final String FEE_CATEGORY_FREIGHT = "A01"; + /** 费用大类编码(业务字典 fee_category 键值):装卸 */ + public static final String FEE_CATEGORY_LOADING = "A10"; private final IBfiService bfiService; private final ContractManageMapper contractManageMapper; @@ -424,7 +424,7 @@ public class KingdeeApBillSyncSupport { ContractManage contract = requireContract(settlement.getContractId(), context); // 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用) Map taxRateByRule = taxRateByRule(contract); - // 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译) + // 费用项档案:费用项名称 -> 费用大类编码(费用大类判定) Map feeCategoryByName = feeCategoryByName(context); EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName); // 结算明细费用行:一车多货按货物拆分的费用快照行 @@ -540,7 +540,7 @@ public class KingdeeApBillSyncSupport { String feeType = Func.toStr(summaryFee.getFeeType()).trim(); String feeItem = Func.toStr(summaryFee.getFeeItem()).trim(); String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]"; - // 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料 + // 非运费类(费用项档案分类编码非 A01 且名称无运费字样)统一映射装卸物料 boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName()); String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc); // 结算合计费用项行无运输量,数量默认 1 @@ -667,12 +667,12 @@ public class KingdeeApBillSyncSupport { /** * 费用项按费用大类/税率拆分(纯函数): - * 费用项经费用项档案/业务字典判为装卸大类(名称含“装卸”的费用类型)、其余归运输大类; + * 费用项经费用项档案判为装卸大类(费用大类编码 A10)、其余归运输大类; * 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组), * 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽 * * @param feeItems 费用项名称 -> 金额(费用项JSON解析结果,保持行序) - * @param feeCategoryByName 费用项档案:费用项名称 -> 费用类型名称(空 Map 时回退名称关键字判定) + * @param feeCategoryByName 费用项档案:费用项名称 -> 费用大类编码(空 Map 时回退名称关键字判定) * @param taxRateByRule 合同计费方案:费用类型/费用项 -> 税率(百分数) * @param rowAmount 货物费用行结算金额(含税),残差勾稽基准 * @return 拆分行列表(保持费用项出现顺序) @@ -720,29 +720,33 @@ public class KingdeeApBillSyncSupport { } /** - * 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用类型名称(业务字典 fee_category 翻译) - * 含“装卸”归装卸大类;档案未维护该费用项时回退费用项名称关键字判定 + * 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用大类编码为 A10 归装卸大类; + * 档案未维护该费用项时回退费用项名称关键字判定 */ static boolean isLoadingCategory(String feeItemName, Map feeCategoryByName) { - String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); - if (Func.isNotBlank(categoryName)) { - return categoryName.contains("装卸"); + String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); + if (Func.isNotBlank(categoryCode)) { + return FEE_CATEGORY_LOADING.equals(categoryCode); } return isLoadingFeeItem(feeItemName); } - /** 运费类费用判定(关键字兜底):名称含“运费”或“运输费” */ + /** 运费类费用判定(关键字兜底):名称含“运费”或“运输”(业务字典 fee_category 运输类的字典值为“运输”) */ static boolean isFreightFeeText(String name) { - return name != null && (name.contains("运费") || name.contains("运输费")); + return name != null && (name.contains("运费") || name.contains("运输")); } /** - * 运费类判定(手动添加费用行物料映射用):费用项档案分类名称、费用类型、费用项名称任一 - * 含“运费/运输费”即归运费类映射运输物料;档案未维护时退化为名称关键字判定(与原口径一致) + * 运费类判定(手动添加费用行物料映射用):费用项档案维护的费用大类编码为 A01 归运费类映射运输物料; + * 档案未维护时回退行费用类型编码与费用项名称关键字判定 */ - static boolean isFreightCategory(String feeTypeName, String feeItemName, Map feeCategoryByName) { - String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); - return isFreightFeeText(categoryName) || isFreightFeeText(feeTypeName) || isFreightFeeText(feeItemName); + public static boolean isFreightCategory(String feeTypeCode, String feeItemName, Map feeCategoryByName) { + String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); + if (Func.isNotBlank(categoryCode)) { + // 档案维护的费用项以档案分类编码为准 + return FEE_CATEGORY_FREIGHT.equals(categoryCode); + } + return FEE_CATEGORY_FREIGHT.equals(feeTypeCode) || isFreightFeeText(feeItemName); } /** @@ -844,8 +848,8 @@ public class KingdeeApBillSyncSupport { } /** - * 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典 - * {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用; + * 费用项档案分类:费用项名称 -> 费用大类编码(blade_fee_item.fee_category,业务字典 fee_category 键值, + * 运输 A01 / 装卸 A10;批量同步时全批共享一次加载结果),费用大类判定用; * 档案读取失败直接拦截 */ public Map feeCategoryByName(KingdeeSyncContext context) { @@ -853,8 +857,6 @@ public class KingdeeApBillSyncSupport { if (!cache.isEmpty()) { return cache; } - Map categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream() - .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); R> response = sysClient.getFeeItems(false); if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试"); @@ -864,10 +866,9 @@ public class KingdeeApBillSyncSupport { if (feeItem == null || Func.isBlank(feeItem.getName()) || Func.isBlank(feeItem.getFeeCategory())) { continue; } - loaded.putIfAbsent(feeItem.getName().trim(), - categoryNames.getOrDefault(feeItem.getFeeCategory().trim(), feeItem.getFeeCategory().trim())); + loaded.putIfAbsent(feeItem.getName().trim(), feeItem.getFeeCategory().trim()); } - log.info("费用项档案加载:{}项(费用项名称 -> 费用类型名称)", loaded.size()); + log.info("费用项档案加载:{}项(费用项名称 -> 费用大类编码)", loaded.size()); cache.putAll(loaded); return cache; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java index 9b0575b1..50584e52 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java @@ -86,11 +86,11 @@ class KingdeeEntrySplitTest { assertEquals(new BigDecimal("800"), splits.get(1).getAmount()); } - /** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用类型名称含“装卸”即归装卸大类 */ + /** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用大类编码 A10 即归装卸大类 */ @Test void loadingCategoryFromFeeItemArchive() { Map feeItems = Map.of("吊装作业", new BigDecimal("500")); - Map feeCategoryByName = Map.of("吊装作业", "装卸"); + Map feeCategoryByName = Map.of("吊装作业", "A10"); List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, feeCategoryByName, Map.of(), new BigDecimal("500")); assertEquals(1, splits.size()); assertTrue(splits.get(0).isLoading()); @@ -166,4 +166,23 @@ class KingdeeEntrySplitTest { List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), BigDecimal.ZERO); assertTrue(splits.isEmpty()); } + + /** 手工合计行运费类判定:费用项档案分类编码 A01 归运费类映射运输物料(SJS2026101900004 车辆维修费误映射装卸物料回归用例) */ + @Test + void freightCategoryMatchesArchiveCode() { + Map feeCategoryByName = Map.of( + "车辆维修费", "A01", + "人工装卸费用", "A10"); + assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", feeCategoryByName)); + assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", feeCategoryByName)); + // 档案编码优先于名称关键字:A02 档案的“装卸搬运作业费”不归装卸大类 + Map feeItems = Map.of("装卸搬运作业费", new BigDecimal("100")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, + Map.of("装卸搬运作业费", "A02"), Map.of(), new BigDecimal("100")); + assertFalse(splits.get(0).isLoading()); + // 档案未维护:回退行费用类型编码与名称关键字判定 + assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", Map.of())); + assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", Map.of())); + assertTrue(KingdeeApBillSyncSupport.isFreightCategory("", "运输费", Map.of())); + } } From 20038ae187d43d9a44ff4fe6dbbf39bc92753c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 21:38:28 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=E9=A2=84=E4=BB=98=E8=BD=AC=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E7=BB=93=E8=BD=AC=E6=89=8B=E5=B7=A5=E5=90=88=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E7=94=A8=E8=A1=8C=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E9=87=91=E8=9D=B6=E9=87=91=E9=A2=9D=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rebuildSummaryFees 不再将预结算手工费用行(manualFlag=1)并入自动汇总:金蝶分录只送手工行,并入后被当作明细再汇总跳过,导致 YJ2026101900004 转正式后水费 500 元未推送金蝶 - 预结算手工行结转为正式手工行(同键已存在时沿用页面维护值不重复插入) - applySummaryRequest 空列表视为沿用 rebuild 结果,不再按空请求删光手工行(与预结算同口径) --- .../impl/FormalSettlementServiceImpl.java | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 91268648..345a6a03 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -1349,6 +1349,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl existingManualRows = existingRows.stream() .filter(row -> Integer.valueOf(1).equals(row.getManualFlag())).toList(); Map aggregates = new LinkedHashMap<>(); + // 预结算手工费用行不并入自动汇总:金蝶分录只送手工行(manualFlag=1),并入自动汇总 + // 会被当作明细再汇总跳过,推送金蝶时金额缺失(如预付单手工水费转正式后丢失) + Map sourceManualAmounts = new LinkedHashMap<>(); for (FormalSettlementSource source : sourceMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementSource::getFormalSettlementId, settlementId))) { List sourceSummary = preSummaryFeeMapper.selectList( @@ -1356,9 +1359,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl { - BigDecimal additionalAmount = Integer.valueOf(1).equals(row.getManualFlag()) - ? money(row.getSettlementAmount()) : money(row.getAdjustAmount()); - aggregates.merge(summaryKey(row.getFeeType(), row.getFeeItem()), additionalAmount, BigDecimal::add); + if (Integer.valueOf(1).equals(row.getManualFlag())) { + sourceManualAmounts.merge(summaryKey(row.getFeeType(), row.getFeeItem()), + money(row.getSettlementAmount()), BigDecimal::add); + } else { + aggregates.merge(summaryKey(row.getFeeType(), row.getFeeItem()), money(row.getAdjustAmount()), BigDecimal::add); + } }); } Map feeTypeMap = feeTypeMap(); @@ -1393,6 +1399,25 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl entry : sourceManualAmounts.entrySet()) { + boolean existed = existingManualRows.stream().anyMatch(row -> summaryKey(row).equals(entry.getKey())); + if (existed) { + continue; + } + String[] keyParts = entry.getKey().split("@@@", 2); + FormalSettlementSummaryFee manualRow = new FormalSettlementSummaryFee(); + manualRow.setFormalSettlementId(settlementId); + manualRow.setFeeType(keyParts[0]); + manualRow.setFeeItem(keyParts.length > 1 ? keyParts[1] : ""); + manualRow.setOriginalAmount(BigDecimal.ZERO.setScale(2)); + manualRow.setAdjustAmount(money(entry.getValue())); + manualRow.setSettlementAmount(money(entry.getValue())); + manualRow.setRemark(""); + manualRow.setManualFlag(1); + manualRow.setLineNo(lineNo++); + summaryFeeMapper.insert(manualRow); + } renumberSummaryFees(settlementId); } @@ -1489,7 +1514,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl requestRows) { - if (requestRows == null) return; + if (requestRows == null) { + return; + } + // 空列表表示沿用 rebuildSummaryFees 结果(如预结算转正式结转的手工合计行),不得按空请求删光手工行 + if (requestRows.isEmpty()) { + renumberSummaryFees(settlementId); + return; + } Map> allowedManualFees = new LinkedHashMap<>(); for (Map option : preSettlementService.feeOptions()) { Set feeItems = new LinkedHashSet<>(); From 44e3ecfaa23c5619cf9a6c89fe6b6d7e9afb3f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Mon, 19 Oct 2026 23:30:04 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E5=BC=80=E7=A5=A8=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E6=94=AF=E6=8C=81=E7=BB=93=E7=AE=97=E5=8D=95=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E9=80=89=E6=8B=A9=E5=B9=B6=E8=87=AA=E5=8A=A8=E5=88=86?= =?UTF-8?q?=E6=91=8A=E6=8E=A7=E5=88=B6=E7=B4=AF=E8=AE=A1=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 结算单候选接口放开未收票过滤,仅保留剩余可开票金额>0,响应新增已申请开票金额 - 保存申请时本次开票金额按选择顺序自动分摊到各结算单登记占用,摊不完报错,删除新建时未收票硬校验 - 详情实时重算已选结算单剩余可开票金额(排除本申请) - 附改造说明与操作文档 --- .../InvoiceApplicationController.java | 5 +- .../service/IInvoiceApplicationService.java | 2 +- .../impl/InvoiceApplicationServiceImpl.java | 54 +++++--- ...¨申请支持结算单重复选择与累计额度控制改造说明.md | 116 ++++++++++++++++++ 4 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java index 6d2ab648..bbe0bcf3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java @@ -84,10 +84,9 @@ public class InvoiceApplicationController extends BladeController { public R>> settlementCandidates(Query query, @RequestParam(required = false) String keyword, @RequestParam(required = false) String contractCategory, - @RequestParam(defaultValue = "receivable") String settlementType, - @RequestParam(defaultValue = "unreceived") String invoiceStatus) { + @RequestParam(defaultValue = "receivable") String settlementType) { return R.data(invoiceApplicationService.settlementCandidates( - Condition.getPage(query), keyword, contractCategory, settlementType, invoiceStatus)); + Condition.getPage(query), keyword, contractCategory, settlementType)); } @GetMapping("/settlement-details") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java index aa9faf18..3277eef2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java @@ -45,7 +45,7 @@ public interface IInvoiceApplicationService extends BaseService selectPage(IPage page, InvoiceApplicationVO query); InvoiceApplicationVO detail(Long id); IPage> settlementCandidates(IPage page, String keyword, String contractCategory, - String settlementType, String invoiceStatus); + String settlementType); List settlementDetails(String settlementIds); Map receiverInformation(String settlementIds); Long saveDraft(InvoiceApplicationSaveRequest request); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index 4459618e..0c8dad1e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -231,9 +231,23 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) - .orderByAsc(InvoiceApplicationSettlement::getCreateTime))); + // 剩余可开票金额按当前占用实时重算(排除本申请),避免编辑时展示保存时点的过期额度 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) + .orderByAsc(InvoiceApplicationSettlement::getCreateTime)); + if (!relations.isEmpty()) { + Map settlementMap = formalSettlementMapper.selectBatchIds( + relations.stream().map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList()) + .stream().collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, duplicate) -> first)); + relations.forEach(relation -> { + FormalSettlement settlement = settlementMap.get(relation.getFormalSettlementId()); + if (settlement != null) { + relation.setAvailableInvoiceAmount(availableAmount(settlement, id)); + } + }); + } + vo.setSettlements(relations); List sheets = sheetMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo)); vo.setSheets(sheets.stream().map(sheet -> { @@ -255,9 +269,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl> settlementCandidates(IPage page, String keyword, - String contractCategory, String settlementType, String invoiceStatus) { + String contractCategory, String settlementType) { String candidateSettlementType = Func.isEmpty(settlementType) ? "receivable" : settlementType; - String candidateInvoiceStatus = Func.isEmpty(invoiceStatus) ? "unreceived" : invoiceStatus; List contractIds = Func.isEmpty(contractCategory) ? List.of() : contractManageMapper.selectList(Wrappers.lambdaQuery() .select(ContractManage::getId) @@ -269,7 +282,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl settlements = formalSettlementMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlement::getSettlementType, candidateSettlementType) - .eq(FormalSettlement::getInvoiceStatus, candidateInvoiceStatus) .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .eq(FormalSettlement::getStatus, 1) .in(Func.isNotEmpty(contractCategory), FormalSettlement::getContractId, contractIds) @@ -337,6 +349,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl first, LinkedHashMap::new)).values().stream().toList(); List settlements = requestedRows.stream().map(row -> availableSettlement(row.getSettlementId())).toList(); - if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()) - || (creating && !"unreceived".equals(settlement.getInvoiceStatus())))) { - throw new ServiceException("只能选择审批通过、未作废、未收票的应收正式结算单"); + if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()))) { + throw new ServiceException("只能选择审批通过、未作废的应收正式结算单"); } assertCompatible(settlements); FormalSettlement first = settlements.get(0); Map settlementMap = settlements.stream() .collect(Collectors.toMap(FormalSettlement::getId, Function.identity())); - BigDecimal totalAvailable = BigDecimal.ZERO; - for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) { - FormalSettlement settlement = settlementMap.get(row.getSettlementId()); - BigDecimal available = availableAmount(settlement, entity.getId()); - BigDecimal allocated = nonNegative(row.getAllocatedInvoiceAmount(), "分摊发票金额"); - if (allocated.compareTo(available) > 0) { - throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的分摊金额超过剩余可开票金额"); - } - totalAvailable = totalAvailable.add(available); - } BigDecimal lineTotal = validateSheets(request.getSheets()); if (lineTotal.compareTo(BigDecimal.ZERO) <= 0) { throw new ServiceException("本次开票金额必须大于0"); } + // 本次开票金额按选择顺序自动分摊到各结算单并登记累计占用,摊不完即超过可开票金额 + BigDecimal totalAvailable = BigDecimal.ZERO; + BigDecimal remaining = lineTotal; + for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) { + FormalSettlement settlement = settlementMap.get(row.getSettlementId()); + BigDecimal available = availableAmount(settlement, entity.getId()); + totalAvailable = totalAvailable.add(available); + BigDecimal allocated = available.min(remaining); + row.setAllocatedInvoiceAmount(allocated); + remaining = remaining.subtract(allocated); + } + if (remaining.compareTo(BigDecimal.ZERO) > 0) { + throw new ServiceException("本次开票金额不能超过可开票金额"); + } CustomerArchive customer = findCustomer(first.getPayerName()); if (customer == null) throw new ServiceException("未找到正式结算单受票方对应的客商档案"); List invoiceInfos = activeInvoiceInfoVOs(customer.getId()); diff --git a/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md b/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md new file mode 100644 index 00000000..e3a19b2a --- /dev/null +++ b/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md @@ -0,0 +1,116 @@ +# 开票申请支持结算单重复选择与累计额度控制改造说明 + +> 日期:2026-10-19 +> 模块:收款付账管理 / 开票管理(开票申请) +> 涉及工程:`tms-api`(blade-transport)、`tms-erp-web` +> 关联功能说明:支持多张结算单合并开票、一张结算单拆分多张发票;审批通过后同步金蝶;单据留痕。 + +--- + +## 一、背景与现状问题 + +开票申请原实现中,选单弹窗与保存校验都要求结算单处于"未收票"(`unreceived`)状态: + +1. 前端选单请求写死 `invoiceStatus: 'unreceived'`; +2. 后端候选接口按 `invoice_status = 'unreceived'` 过滤; +3. 保存校验:新建申请时若结算单状态不是 `unreceived` 直接报"只能选择审批通过、未作废、未收票的应收正式结算单"; +4. 开票申请**保存草稿**时即回写结算单发票状态(`refreshSettlementInvoiceStatus`),状态变为"部分开票 / 全额开票"。 + +**结果**:结算单 001、002 在第一张开票申请保存后状态不再是"未收票",开第二张票时弹窗查不到、后端也拦截,**一张结算单无法拆分多次开票**,与功能说明中"一张结算单拆分多张发票"不符。 + +同时原"分摊发票金额"依赖用户手填,且存在口径不一致:单选结算单时自动填满剩余额度(即使本次只开一部分),多选时全部填 0(不占用),累计额度控制实际失效。 + +## 二、改造方案(自动分摊口径) + +核心思路:**放开"未收票"过滤让结算单可重复选;"累计开票金额不超结算单金额"由后端按本次开票金额自动分摊登记占用**,复用既有的剩余额度计算与状态回写链路。 + +### 2.1 核心规则 + +| 规则 | 说明 | +| --- | --- | +| 可开票金额(剩余) | `结算单总金额 − Σ分摊发票金额`,其中分摊金额只统计**未作废、未删除**的开票申请(`availableAmount`,编辑时排除本申请自身) | +| 可选结算单范围 | 审批通过 + 未作废 + 启用 + **剩余可开票金额 > 0** 的应收正式结算单;不再限制"未收票"。开满的(剩余额度为 0)自动从弹窗消失 | +| 自动分摊 | 保存申请(含草稿)时,后端将**本次开票金额**(开票内容商品行含税金额合计)按结算单选择顺序**逐单填满**:每单分摊 `min(该单剩余可开票金额, 尚未摊完的金额)`,分摊结果写入关联表 `allocated_invoice_amount` | +| 整体兜底校验 | 摊不完(本次开票金额 > Σ剩余可开票金额)时保存报错:"本次开票金额不能超过可开票金额",草稿与提交均生效 | +| 结算单发票状态 | 保存/删除/作废申请时自动刷新:占用 ≤ 0 → 未收票;0 < 占用 < 总金额 → 部分开票;占用 = 总金额 → 全额开票(`refreshSettlementInvoiceStatus`,本次未改动) | +| 额度释放 | 删除草稿、作废申请时自动释放占用(既有逻辑,本次未改动) | + +### 2.2 分摊示例 + +结算单 001 总金额 1000: + +| 步骤 | 操作 | 分摊结果 | 001 状态 | 剩余可开票 | +| --- | --- | --- | --- | --- | +| 第 1 张申请 | 选 001,本次开票 600 | 001 分摊 600 | 部分开票 | 400 | +| 第 2 张申请 | 弹窗仍可选 001(显示已申请 600 / 可开票 400),本次开票 400 | 001 分摊 400 | 全额开票 | 0 | +| 第 3 张申请 | 弹窗不再出现 001(剩余额度 0) | — | — | — | +| 第 2 张申请被作废 | 释放 400 | 001 分摊余 600 | 部分开票 | 400 | + +多张结算单(001 剩 1000、002 剩 500)本次开票 1200:001 分摊 1000、002 分摊 200;本次开票 1600 时摊不完,保存报错。 + +## 三、改动明细 + +### 3.1 后端(tms-api / blade-transport) + +| 文件 | 改动 | +| --- | --- | +| `IInvoiceApplicationService.java` | `settlementCandidates` 签名删除 `invoiceStatus` 参数 | +| `InvoiceApplicationController.java` | `/settlement-candidates` 删除 `invoiceStatus` 请求参数(原默认 `unreceived`) | +| `InvoiceApplicationServiceImpl.java` `settlementCandidates` | 删除 `invoice_status = 'unreceived'` 过滤;"剩余可开票金额 > 0"过滤保留,兜住开满的结算单 | +| `InvoiceApplicationServiceImpl.java` `settlementCandidateMap` | 候选行新增返回 `invoicedAmount`(已申请开票金额 = 结算总金额 − 剩余可开票) | +| `InvoiceApplicationServiceImpl.java` `saveDraft` | ① 删除新建时 `unreceived` 状态硬校验,文案改为"只能选择审批通过、未作废的应收正式结算单";② 先计算本次开票金额(`validateSheets`),再按 2.1 规则自动分摊并回写 `SettlementRow.allocatedInvoiceAmount`,摊不完报错。关联表保存(`saveRelations`)直接读取分摊结果,无需感知 | +| `InvoiceApplicationServiceImpl.java` `detail` | 对已选结算单实时重算剩余可开票金额(`availableAmount` 排除本申请),避免编辑时展示保存时点的过期额度;批量查询结算单避免 N+1 | + +### 3.2 前端(tms-erp-web / `src/views/payment/invoice-application-form.vue`) + +| 位置 | 改动 | +| --- | --- | +| `loadSettlementCandidates` | 删除 `invoiceStatus: 'unreceived'` 参数 | +| 选单弹窗 | 在"结算金额"与"可开票金额"之间新增"已申请开票金额"列(`invoicedAmount`);"可开票金额"列原本已有 | +| `applySettlements` | 选单后分摊金额统一默认 0(删除"单选自动填满剩余额度"逻辑),分摊由后端保存时自动计算 | +| 结算信息表格 | "分摊发票金额"列改为只读展示(保存/详情返回的自动分摊结果),不再允许手填 | + +提交前的整体校验"本次开票金额不能超过可开票金额"(`validateAvailableInvoiceAmount`)保留,用于提交时提前拦截;后端保存兜底,草稿同样受限。 + +## 四、操作说明 + +### 4.1 制单人:首张开票(含草稿) + +1. 开票管理 → 开票申请 → 新增; +2. 结算单"选择":弹窗展示审批通过的应收正式结算单,可见**结算金额 / 已申请开票金额 / 可开票金额**三列,可多选合并开票(需同一合同、项目、组织及收付款方); +3. 勾选开票明细、维护开票内容商品行,本次开票金额由商品行含税金额自动求和; +4. 保存/提交:系统自动把本次开票金额按选择顺序分摊到各结算单并登记占用,结算单状态刷新为"部分开票"或"全额开票"。 + +### 4.2 制单人:同一结算单拆分开第二张票(本次新增能力) + +1. 再次新增开票申请,选单弹窗中**已开过票的结算单仍会出现**(只要还有剩余额度); +2. "可开票金额"列显示的即为剩余额度(总金额 − 已申请); +3. 本次开票金额超过剩余额度时,保存/提交均提示"本次开票金额不能超过可开票金额",调低商品行金额后可正常保存; +4. 开满后该结算单自动从弹窗消失。 + +### 4.3 编辑与释放 + +- 编辑已有申请:结算信息中"可开票金额"为实时剩余额度(已排除本申请自身占用),"分摊发票金额"为上次保存的自动分摊结果;再次保存时按新的本次开票金额重新分摊; +- 删除草稿 / 作废申请:占用自动释放,对应结算单恢复可开额度并回刷发票状态。 + +## 五、兼容性与注意事项 + +| 项 | 说明 | +| --- | --- | +| 存量数据 | 历史"多选分摊=0"的申请未登记占用,其结算单会以"全额可开"出现在弹窗。如需严格口径,上线前可做数据订正:按未作废申请的 `invoice_amount` 回填关联表 `allocated_invoice_amount` 并刷新结算单状态;或明确存量不追溯 | +| 草稿即占用 | 保存草稿即登记占用(与原行为一致),长期挂着的草稿会占住额度,需作废/删除释放 | +| 明细不去重 | 同一结算单的运单明细可能被多张申请勾选,本次按金额总额控制,不按明细去重(需求未要求) | +| 手填分摊失效 | `allocatedInvoiceAmount` 请求字段后端不再采信(自动覆盖),前端已改只读;如有第三方调用直接传该字段,行为变化需知悉 | +| 金蝶同步 | 同步链路读取申请主表/商品行,未读取分摊字段,理论无影响;上线后建议回归一次"审批通过 → 同步金蝶"链路 | +| 接口变更 | `/invoice-application/settlement-candidates` 删除 `invoiceStatus` 参数、响应新增 `invoicedAmount`,仅开票申请选单使用 | + +## 六、验证清单 + +- [ ] 新增申请选择 001 开票 600,保存后 001 状态"部分开票"、已申请 600; +- [ ] 第二张申请弹窗可选 001,可开票金额显示 400,已申请开票金额显示 600; +- [ ] 第二张本次开票 500,保存报"本次开票金额不能超过可开票金额";调为 400 保存成功,001 变"全额开票"; +- [ ] 第三张申请弹窗不再出现 001; +- [ ] 作废第二张申请后,001 恢复可开票 400、状态回到"部分开票"; +- [ ] 多结算单合并开票:本次开票金额按顺序分摊到各单,结算信息表格展示各单分摊结果; +- [ ] 编辑申请时"可开票金额"为实时额度(扣除其他申请占用、含本申请原分摊回退); +- [ ] 原有链路回归:审批流转、金蝶同步、批量同步状态、留痕记录。 From a8fa21c13e1c7da738a4be6b172c39f169e98945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Tue, 20 Oct 2026 17:41:13 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EBFI=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=96=B9=E6=B3=95=E7=BA=A7=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A1=A9Mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thirdParty.bfi.mock-enabled=true 时经 BfiMockConfig 注册 @Primary 的 IBfiClient 代理,{mockDir}/{方法名}.json 有桩走桩、无桩透传真实 Feign,调用侧零改动;桩文件每次调用现读,解析失败返回失败结果而非 null;invoiceDownload 返回 feign.Response 始终透传。 --- .../thirdparty/bfi/config/BfiMockConfig.java | 64 +++++++ .../thirdparty/bfi/config/BfiProperties.java | 7 +- .../thirdparty/bfi/mock/BfiClientMock.java | 176 ++++++++++++++++++ doc/nacos/third-party-api.yaml | 5 + 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java create mode 100644 blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java new file mode 100644 index 00000000..fcf24015 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java @@ -0,0 +1,64 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.config; + +import org.springblade.thirdparty.bfi.feign.IBfiClient; +import org.springblade.thirdparty.bfi.mock.BfiClientMock; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * BFI 客户端 Mock 配置 + *

+ * thirdParty.bfi.mock-enabled=true(Nacos 单入口总开关)时注册 @Primary 的 mock 实现, + * 覆盖同类型 Feign 客户端 bean,{@link org.springblade.thirdparty.bfi.service.impl.BfiServiceImpl} 注入的 {@link IBfiClient} + * 无感知切换为"有桩用桩、没桩走接口":{mockDir}/{方法名}.json 有桩走本地 JSON 桩, + * 无桩透传真实 Feign 接口({@link BfiClientMock},目录经 thirdParty.bfi.mockDir 配置),调用侧零改动。 + * 仅本地自测/演示环境开启,生产必须保持 false。 + * + * @author Chill + */ +@Configuration +public class BfiMockConfig { + + /** + * mock 版 BFI 客户端(@Primary 优先于 Feign 客户端注入) + * + * @param properties BFI 配置(取桩文件目录 mockDir) + * @param realClientProvider 真实客户端候选 + * @return mock 客户端 + */ + @Bean + @Primary + // @ConditionalOnProperty 不支持宽松绑定,key 必须与 Nacos 实际键完全一致(本仓库 Nacos 为 thirdParty.bfi.* 驼峰根) + @ConditionalOnProperty(name = "thirdParty.bfi.mock-enabled", havingValue = "true") + public IBfiClient bfiClientMock(BfiProperties properties, ObjectProvider realClientProvider) { + return BfiClientMock.create(properties.getMockDir(), realClientProvider); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java index dd979537..fd0ef5de 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java @@ -65,7 +65,12 @@ public class BfiProperties { */ private String language = "zh_CN"; /** - * 是否启用 mock 模式(开发调试用) + * 是否启用 mock 模式(开发调试用):开启后 IBfiClient 走本地桩目录(有桩用桩、无桩透传真实接口,见 BfiClientMock) */ private boolean mockEnabled = false; + /** + * mock 客户端桩文件目录,文件名为接口方法名:{mockDir}/{方法名}.json,如 payApplyQuery.json; + * 仅 mock-enabled=true 生效,文件每次调用现读,改文件即生效 + */ + private String mockDir = "./mock/bfi"; } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java new file mode 100644 index 00000000..795d324c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java @@ -0,0 +1,176 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.mock; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.type.TypeFactory; +import feign.Response; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.thirdparty.bfi.exception.BfiException; +import org.springblade.thirdparty.bfi.feign.IBfiClient; +import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springframework.beans.factory.ObjectProvider; + +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * BFI 客户端本地 Mock(thirdParty.bfi.mock-enabled=true 时经 BfiMockConfig 注册为 @Primary,包装真实 Feign 客户端) + *

+ * 单入口总开关,方法级混合:每个 IBfiClient 方法对应一个外部目录桩文件 {mockDir}/{方法名}.json(thirdParty.bfi.mockDir 配置), + * 有桩即走 mock(内容即金蝶真实响应报文,含 BfiResultVO/BfiOpenApiResultVO 包装,按方法返回泛型反序列化,调用侧无感知), + * 无桩自动透传真实 Feign 接口,实现"有桩用桩、没桩走接口"。 + * 桩文件每次调用现读,改文件即生效无需重启;桩解析失败返回失败的 BfiResultVO,不静默吞错。 + * 文件下载接口 invoiceDownload 返回 feign.Response,无法按 JSON 桩反序列化,始终透传真实接口。 + * + * @author Chill + */ +@Slf4j +public class BfiClientMock implements InvocationHandler { + + /** + * 桩文件后缀(文件名即接口方法名) + */ + private static final String MOCK_FILE_SUFFIX = ".json"; + + /** + * 桩文件目录 + */ + private final Path mockDir; + + /** + * 真实客户端候选(排除 mock 自身,运行期首次透传时解析并缓存) + */ + private final ObjectProvider realClientProvider; + + /** + * 真实 Feign 客户端缓存(double-checked locking) + */ + private volatile IBfiClient realClient; + + /** + * 构建 mock 版 IBfiClient + * + * @param mockDir 桩文件目录(third-party.bfi.mock-dir) + * @param realClientProvider 真实客户端候选(容器内 IBfiClient 全量,含 mock 自身,解析时排除) + * @return mock 客户端 + */ + public static IBfiClient create(String mockDir, ObjectProvider realClientProvider) { + return (IBfiClient) Proxy.newProxyInstance(IBfiClient.class.getClassLoader(), + new Class[]{IBfiClient.class}, new BfiClientMock(mockDir, realClientProvider)); + } + + private BfiClientMock(String mockDir, ObjectProvider realClientProvider) { + this.mockDir = Path.of(mockDir); + this.realClientProvider = realClientProvider; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // Object 基础方法(toString/hashCode/equals)直接执行 + if (method.getDeclaringClass() != IBfiClient.class) { + return method.invoke(this, args); + } + // 文件下载返回 feign.Response,无法按 JSON 桩反序列化,始终透传 + if (method.getReturnType() == Response.class) { + return this.invokeReal(method, args, proxy); + } + return this.loadMock(method, args, proxy); + } + + /** + * 有桩读桩,无桩透传真实接口 + * + * @param method 接口方法 + * @param args 入参 + * @param proxy mock 代理自身(解析真实客户端时排除) + * @return 桩数据或真实接口响应 + */ + private Object loadMock(Method method, Object[] args, Object proxy) throws Throwable { + Path mockFile = this.mockDir.resolve(method.getName() + MOCK_FILE_SUFFIX); + if (!Files.exists(mockFile)) { + log.debug("BFI mock 桩文件缺失,透传真实接口:{}", method.getName()); + return this.invokeReal(method, args, proxy); + } + // 外部桩每次调用现读,改文件即生效 + try (InputStream inputStream = Files.newInputStream(mockFile)) { + JavaType javaType = TypeFactory.defaultInstance().constructType(method.getGenericReturnType()); + return JsonUtil.getInstance().readValue(inputStream, javaType); + } catch (Exception exception) { + log.error("BFI mock 桩文件解析失败:{}", mockFile.toAbsolutePath(), exception); + return this.failResult("BFI mock 桩文件解析失败:" + mockFile.toAbsolutePath()); + } + } + + /** + * 透传真实 Feign 客户端(首次调用时懒解析,排除 mock 代理自身,异常原样抛出) + * + * @param method 接口方法 + * @param args 入参 + * @param proxy mock 代理自身 + * @return 真实接口响应 + * @throws Throwable 真实调用异常(Feign 降级/业务异常原样透传) + */ + private Object invokeReal(Method method, Object[] args, Object proxy) throws Throwable { + IBfiClient real = this.realClient; + if (real == null) { + synchronized (this) { + if (this.realClient == null) { + this.realClient = this.realClientProvider.stream() + .filter(client -> client != proxy) + .findFirst() + .orElseThrow(() -> new BfiException("BFI mock 桩缺失且未找到真实 BFI 客户端:" + method.getName())); + } + real = this.realClient; + } + } + try { + return method.invoke(real, args); + } catch (InvocationTargetException exception) { + throw exception.getCause(); + } + } + + /** + * 构造失败的 BfiResultVO(桩解析异常时兜底,避免调用侧拿到 null) + * + * @param msg 失败原因 + * @return 失败结果 + */ + private BfiResultVO failResult(String msg) { + BfiResultVO result = new BfiResultVO<>(); + result.setStatus(false); + result.setErrorCode("errors.mock"); + result.setMessage(msg); + return result; + } +} diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index 6e390a3d..41dd3399 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -47,6 +47,11 @@ thirdParty: accountId: ${BFI_ACCOUNT_ID:} # 语言字串,默认 zh_CN language: ${BFI_LANGUAGE:zh_CN} + # BFI 客户端本地 Mock 开关(联调自测用,生产必须 false): + # true 时 IBfiClient 有桩用桩({mockDir}/{接口方法名}.json)、无桩透传真实 Feign 接口,调用侧零改动 + mock-enabled: ${BFI_MOCK_ENABLED:false} + # Mock 桩文件目录:文件名为接口方法名,如 getToken.json、payApplyQuery.json;文件每次调用现读,改文件即生效 + mockDir: ${BFI_MOCK_DIR:./mock/bfi} # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) srcSys: ${BFI_SRC_SYS:WLTMS} # 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值): From a61509cc42fa73e70ca87e6b3ba109e61e0c965b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Wed, 21 Oct 2026 14:42:40 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E5=8D=95=E5=90=88=E5=B9=B6=E4=BB=98=E6=AC=BE=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=98=BE=E7=A4=BA=E6=95=B4=E5=8D=95=E9=87=91=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/FormalSettlementServiceImpl.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index 345a6a03..69ce83c7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -85,6 +85,7 @@ import org.springblade.transport.pojo.entity.SettlementAdjustment; import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.FormalSettlementVO; +import org.springblade.transport.pojo.vo.PaymentApplicationVO; import org.springblade.transport.pojo.vo.PreSettlementVO; import org.springblade.transport.pojo.vo.SettlementAdvancePaymentVO; import org.springblade.transport.pojo.vo.SettlementAdjustmentVO; @@ -327,13 +328,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relationApplicationIds = paymentApplicationSettlementMapper.selectList( + Map relationByApplicationId = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() - .select(PaymentApplicationSettlement::getPaymentApplicationId) .eq(PaymentApplicationSettlement::getFormalSettlementId, id) .eq(PaymentApplicationSettlement::getIsDeleted, 0)).stream() - .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull) - .collect(Collectors.toSet()); + .filter(relation -> relation.getPaymentApplicationId() != null) + .collect(Collectors.toMap(PaymentApplicationSettlement::getPaymentApplicationId, relation -> relation, + (first, second) -> first)); + Set relationApplicationIds = relationByApplicationId.keySet(); LambdaQueryWrapper paymentApplicationQuery = Wrappers.lambdaQuery(); paymentApplicationQuery.and(query -> { query.eq(PaymentApplication::getSettlementId, id); @@ -345,7 +347,16 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl PaymentApplicationWrapper.build().entityVO(item)).toList()); + .map(item -> { + PaymentApplicationVO application = PaymentApplicationWrapper.build().entityVO(item); + // 多结算单合并付款时,付款申请金额为整单合计;详情按当前结算单关联行展示分摊金额 + PaymentApplicationSettlement relation = relationByApplicationId.get(item.getId()); + if (relation != null) { + application.setAppliedAmount(money(relation.getAppliedAmount())); + application.setPaidAmount(money(relation.getPaidAmount())); + } + return application; + }).toList()); vo.setAdjustments(settlementAdjustmentMapper.selectList(Wrappers.lambdaQuery() .eq(SettlementAdjustment::getFormalSettlementId, id) .orderByDesc(SettlementAdjustment::getCreateTime)).stream() From a17a0a8b1e4ee2c2c11a5e2a6bd91d682a9191b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Wed, 21 Oct 2026 14:43:04 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A2=84=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E8=BD=AC=E6=AD=A3=E5=BC=8F=E5=90=8E=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E6=9C=AA=E6=89=A3=E5=87=8F=E9=A2=84=E4=BB=98?= =?UTF-8?q?=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/PaymentApplicationServiceImpl.java | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index 2836e87d..d9524ac5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -321,11 +321,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() + .select(FormalSettlementSource::getAdvancePaidAmount) + .eq(FormalSettlementSource::getFormalSettlementId, formalSettlementId) + .eq(FormalSettlementSource::getIsDeleted, 0)).stream() + .map(FormalSettlementSource::getAdvancePaidAmount) + .map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + /** 预结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 */ private BigDecimal payableOfPreSettlement(Long preSettlementId, BigDecimal settlementAmount, Long excludeId) { return money(settlementAmount) From 5bae143e0d91c95adec3fa2e4db26216fac4abd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Wed, 21 Oct 2026 15:28:57 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BC=80=E5=90=AF?= =?UTF-8?q?BFI=20Mock=E6=97=B6=E5=8F=8CPrimary=E5=AF=BC=E8=87=B4=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/springblade/thirdparty/bfi/feign/IBfiClient.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java index fea916a7..8e321535 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java @@ -85,7 +85,8 @@ import java.util.List; * * @author Chill */ -@FeignClient(name = "BFI", url = "${thirdParty.bfi.baseUrl}", configuration = BfiFeignClientConfig.class) +// primary = false:Feign 客户端默认 primary=true,开启 mock(BfiMockConfig 注册 @Primary 实现)会双 primary 启动失败 +@FeignClient(name = "BFI", url = "${thirdParty.bfi.baseUrl}", configuration = BfiFeignClientConfig.class, primary = false) public interface IBfiClient { String GET_TOKEN_URL = "${thirdParty.bfi.getTokenUrl:/ierp/kapi/oauth2/getToken}"; From 47aa4a486a00444f49e52cc5d34a3fef7efe1cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Wed, 21 Oct 2026 15:49:37 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=A2?= =?UTF-8?q?=E5=95=86=E6=B2=BF=E7=94=A8=E5=89=8D=E7=AB=AF=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E7=9A=84=E9=87=91=E8=9D=B6=E5=AE=A2=E5=95=86=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/service/impl/CustomerArchiveServiceImpl.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index ab5f19fb..bb762f25 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -277,7 +277,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl