feat: 应付应收收票单分录按货物-费用大类-税率拆分并按配置映射金蝶物料编码

结算明细解析收口至应付支撑组件共享:一车多货按货物拆、费用大类(费用项档案+fee_category字典判定,关键字兜底)拆、合同计费方案税率拆行(运输类每行取运输量、装卸类数量1);残差并入运输类无税率组保证金额勾稽;手动添加结算合计费用每行单独一条(非运费类映射装卸物料);物料编码/计量单位映射调整:物料编码收口 Nacos thirdparty.bfi.material.* 配置(去兜底),计量单位非必填不再解析传参;移除三处联调调试块与废弃物料常量
This commit is contained in:
刘泉佳
2026-10-13 11:34:21 +08:00
parent 3b7559e9d6
commit f2b00b22af
6 changed files with 390 additions and 310 deletions
@@ -30,6 +30,9 @@ import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/** /**
* 业财融合(BFI)推送金蝶配置属性,统一收口 Nacos 配置 thirdParty.bfi.*: * 业财融合(BFI)推送金蝶配置属性,统一收口 Nacos 配置 thirdParty.bfi.*:
* <p> * <p>
@@ -70,6 +73,13 @@ public class BfiProperties {
*/ */
private ClaimAnnounce claimAnnounce = new ClaimAnnounce(); private ClaimAnnounce claimAnnounce = new ClaimAnnounce();
/**
* 应付/应收/收票单分录物料映射配置(A102-2/A302-2/A303-2 共用):
* 运输类按项目业务模式映射——国内运输按运单运输方式映射、国际运输映射国际货代服务;
* 装卸大类及手动添加的非运费杂费映射装卸物料
*/
private Material material = new Material();
/** /**
* 付款申请推送配置项 * 付款申请推送配置项
*/ */
@@ -136,4 +146,33 @@ public class BfiProperties {
private String claimusers; private String claimusers;
} }
/**
* 分录物料映射配置项(thirdparty.bfi.material.*):默认值与金蝶测试档案对齐,
* 金蝶正式档案确认后直接在 Nacos 覆盖
*/
@Data
public static class Material {
/**
* 国内运输:运单运输方式 -> 金蝶物料编码(公路运输/铁路运输/水路运输/航空运输四种),
* 运输方式未配置映射时直接拦截报错
*/
private Map<String, String> domestic = new LinkedHashMap<>() {{
put("公路运输", "XDWL11010001");
put("铁路运输", "XDWL11010002");
put("水路运输", "XDWL11010003");
put("航空运输", "Item-00001082");
}};
/**
* 国际运输业务模式物料编码(国际货运代理服务)
*/
private String international = "Item-00001081";
/**
* 装卸大类、手动添加非运费杂费物料编码(装卸)
*/
private String loading = "XDWL11002004";
}
} }
@@ -59,27 +59,6 @@ public final class KingdeeBfiConstant {
/** 联调默认项目编码:金蝶测试档案值,项目档案与金蝶对齐后替换。 */ /** 联调默认项目编码:金蝶测试档案值,项目档案与金蝶对齐后替换。 */
public static final String BFI_AP_BILL_PROJECT_NUMBER_TEST = "999"; public static final String BFI_AP_BILL_PROJECT_NUMBER_TEST = "999";
/** 应付/应收单物料编码:装卸大类费用映射物料。 */
public static final String BFI_MATERIAL_LOADING = "装卸";
/** 应付/应收单物料编码:国际运输业务模式、国际货物运输代理服务项目业务类型映射物料。 */
public static final String BFI_MATERIAL_INTERNATIONAL = "国际货运代理服务费";
/** 应付/应收单物料编码:国内道路货物运输服务前缀,拼接运单运输方式(如 国内道路货物运输服务-公路运输)。 */
public static final String BFI_MATERIAL_DOMESTIC_PREFIX = "国内道路货物运输服务-";
/** 金蝶物料编码映射:物料名称 -> 金蝶物料编码 */
public static final Map<String, String> BFI_MATERIAL_NUMBER_MAP = Map.ofEntries(
Map.entry("国际货运代理服务费", "Item-00001081"),
Map.entry("物流配送", "Item-00001080"),
Map.entry("国内道路货物运输服务-公路运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-水路运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-铁路运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-航空运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-多式联运", "XDWL11010001"),
Map.entry("装卸", "XDWL11010002")
);
/** 付款类型编码:正式付款(结算付款)。 */ /** 付款类型编码:正式付款(结算付款)。 */
public static final String BFI_PAYMENT_TYPE_FORMAL = "201"; public static final String BFI_PAYMENT_TYPE_FORMAL = "201";
@@ -40,16 +40,17 @@ import java.util.Map;
* *
* @author Chill * @author Chill
*/ */
public record KingdeeSyncContext(Map<String, String> measureUnitCodeByName, Map<Long, ContractManage> contractCache, public record KingdeeSyncContext(Map<Long, ContractManage> contractCache,
Map<String, String> customerCodeCache, Map<Long, ProjectApply> projectCache, Map<String, String> customerCodeCache, Map<Long, ProjectApply> projectCache,
Map<String, String> apBillNoCache, Map<String, String> apBillNoCache,
Map<Long, List<FormalSettlementDetail>> settlementDetailCache, Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
Map<Long, FormalSettlement> settlementCache, Map<Long, FormalSettlement> settlementCache,
Map<Long, CustomerReceiptAccount> receiptAccountCache) { Map<Long, CustomerReceiptAccount> receiptAccountCache,
Map<String, String> feeCategoryByName) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */ /** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
public static KingdeeSyncContext empty() { public static KingdeeSyncContext empty() {
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), 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<>());
} }
} }
@@ -32,10 +32,12 @@ import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.api.R; import org.springblade.core.tool.api.R;
import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.cache.SysCache; import org.springblade.system.cache.SysCache;
import org.springblade.system.feign.ISysClient; import org.springblade.system.feign.ISysClient;
import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.MeasurementUnit; 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.BfiApBillQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO;
@@ -82,10 +84,10 @@ import java.util.stream.Collectors;
* 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。 * 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。
* 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件; * 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件;
* 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。</p> * 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。</p>
* <p>明细分录口径:取正式结算单页面结算明细(每行一条)+ 结算合计费用项(每行一条); * <p>明细分录口径:取正式结算单页面结算明细(每行一个运单,按 货物 → 费用大类 → 税率 拆分多行)
* 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber}); * + 结算合计费用项(手动添加的每行一条);
* 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码; * 物料编码按装卸大类/项目业务模式/运单运输方式映射,编码维护在 Nacos 配置 thirdparty.bfi.material.*(见 {@link #resolveMaterialNumber});
* 数量运费费用项目取运输量、其余默认 1;单价按结算金额/数量反算。</p> * 运输类分录数量取运输量(按税率拆行时每行都取运输量)、装卸类默认 1;单价按结算金额/数量反算。</p>
* *
* @author Chill * @author Chill
*/ */
@@ -98,10 +100,10 @@ public class KingdeeApBillSyncSupport {
private static final int PAY_RESULT_QUERY_BATCH = 50; private static final int PAY_RESULT_QUERY_BATCH = 50;
/** A102-4 分页查询页大小 */ /** A102-4 分页查询页大小 */
private static final int PAY_RESULT_PAGE_SIZE = 200; private static final int PAY_RESULT_PAGE_SIZE = 200;
/** 结算明细行(运费)计费方案匹配费用项 */ /** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */
private static final String FREIGHT_FEE_ITEM = "运输费"; private static final String FREIGHT_FEE_ITEM = "运输费";
/** 结算明细行(运费)计费方案匹配费用类型 */ /** 业务字典:费用类型(fee_category),费用项档案分类编码 -> 名称 */
private static final String FREIGHT_FEE_TYPE = "物流配送"; private static final String FEE_CATEGORY_DICT = "fee_category";
private final IBfiService bfiService; private final IBfiService bfiService;
private final ContractManageMapper contractManageMapper; private final ContractManageMapper contractManageMapper;
@@ -204,7 +206,7 @@ public class KingdeeApBillSyncSupport {
* 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。 * 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。
* 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时 * 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录取页面结算明细(每行一条)+ 结算合计费用项(每行一条),结算金额逐行直送; * 明细分录取页面结算明细(每行按 货物→费用大类→税率 拆分)+ 结算合计费用项(每行一条),结算金额逐行直送;
* 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释), * 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释),
* 当前由金蝶按明细自动汇总生成 * 当前由金蝶按明细自动汇总生成
* *
@@ -291,23 +293,17 @@ public class KingdeeApBillSyncSupport {
// 【必填】外部单据类型 // 【必填】外部单据类型
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE); param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE);
// 明细分录:结算明细行 + 结算合计费用项各一条(不聚合) // 明细分录:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条
List<BfiApBillSaveEntryDTO> detailentry = new ArrayList<>(); List<BfiApBillSaveEntryDTO> detailentry = new ArrayList<>();
for (BfiEntryGroup entryGroup : entryGroups) { for (BfiEntryGroup entryGroup : entryGroups) {
BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO(); BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO();
// 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(国内道路货物运输服务-运输方式 / 国际货物运输代理服务 / 装卸) // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置)
String materialNumber = entryGroup.materialNumber(); entry.setMaterialNumber(entryGroup.materialNumber());
entry.setMaterialNumber(materialNumber);
// 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传 // 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传
entry.setAwrpTransport(entryGroup.transportGoods()); entry.setAwrpTransport(entryGroup.transportGoods());
// 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传 // 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传
entry.setAwrpLpn(entryGroup.vehicleNo()); entry.setAwrpLpn(entryGroup.vehicleNo());
// 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码 // 数量:运输类取运输量,装卸类/其它默认 1
entry.setMeasureunitNumber(entryGroup.measureunitNumber());
// 【必填】数量:运费费用项目取运输量,其它费用项目默认 1
entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考) // 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考)
entry.setPrice(entryGroup.price()); entry.setPrice(entryGroup.price());
@@ -322,63 +318,6 @@ public class KingdeeApBillSyncSupport {
// 分录备注(非必传) // 分录备注(非必传)
// entry.setERemark(subject); // entry.setERemark(subject);
// 调试:物料编码和计量单位编码映射(4个固定组合)
if (true) {
// 物料+单位组合映射,只保留【已审核】物料
Map<String, String[]> comboMap = Map.of(
"国际货运代理服务费", new String[]{"Item-00001081", "ci"},
"物流配送", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"},
"国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"}
);
// ⚠️兜底改成【已审核】物流配送 Item-00001080 + TON
String[] defaultCombo = comboMap.get("物流配送");
// 构建组合键:物料名称-计量单位
String measureunitName = entryGroup.measureunitNumber();
String comboKey = materialNumber;
if (materialNumber.contains("国内道路货物运输服务")) {
comboKey = materialNumber + "-" + measureunitName;
}
// 匹配组合,不匹配使用兜底组合
String[] combo = comboMap.getOrDefault(comboKey, defaultCombo);
materialNumber = combo[0];
entry.setMaterialNumber(materialNumber);
entry.setMeasureunitNumber(combo[1]);
}
/*if (true) {
// 物料+单位组合映射
Map<String, String[]> comboMap = Map.of(
"国际货运代理服务费", new String[]{"Item-00001081", "ci"},
"物流配送", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"},
"国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"}
);
// 构建组合键:物料名称-计量单位
String measureunitName = entryGroup.measureunitNumber();
String comboKey = materialNumber;
if (materialNumber.contains("国内道路货物运输服务")) {
comboKey = materialNumber + "-" + measureunitName;
}
// 匹配组合或随机取一个
if (comboMap.containsKey(comboKey)) {
String[] combo = comboMap.get(comboKey);
materialNumber = combo[0];
entry.setMeasureunitNumber(combo[1]);
} else {
List<String[]> combos = new ArrayList<>(comboMap.values());
String[] randomCombo = combos.get(new java.util.Random().nextInt(combos.size()));
materialNumber = randomCombo[0];
entry.setMeasureunitNumber(randomCombo[1]);
}
}*/
detailentry.add(entry); detailentry.add(entry);
} }
param.setDetailentry(detailentry); param.setDetailentry(detailentry);
@@ -432,14 +371,16 @@ public class KingdeeApBillSyncSupport {
} }
/** /**
* 解析单张正式结算单分录:页面结算明细每行一条 + 结算合计费用项每行一条。 * 解析单张正式结算单分录:页面结算明细每行(一个运单)按 货物 → 费用大类 → 税率 拆分多行
* + 手动添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送)。
* <p>取数口径: * <p>取数口径:
* 物料编码按项目业务类型/业务模式/装卸大类映射——装卸大类费用行映射“装卸”;国际运输业务模式 * 结算明细拆行——一车多货(存在货物费用行)先按货物拆;费用项名称含“装卸”归装卸大类、其余归运输大类,
* (或国际货物运输代理服务项目业务类型)映射“国际货物运输代理服务”;国内映射“国内道路货物运输服务-”+运单运输方式; * 各大类内按合同计费方案规则税率分组、不同税率分行,运输类每行数量取运单运输量、装卸类每行数量默认 1,
* 计量单位编码按费用类型/费用项匹配合同计费方案规则计费单位,再按计量单位档案取编码; * 费用项金额合计与结算金额的残差并入运输类无税率组保证勾稽;
* 数量运费费用项目取运输量(未维护兜底 1)、结算合计费用项默认 1;单价按结算金额/数量反算; * 物料编码按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置);
* 单价按结算金额/数量反算;
* 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额; * 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额;
* 运输货物/车号仅结算明细行传(货物名称/车号);分录标识:结算明细行取单据号、 * 运输货物/车号仅结算明细拆分行传(货物名称/车号);分录标识:结算明细拆分行取单据号-货物序号-大类-税率、
* 结算合计费用项行取结算单号-费用类型-费用项拼接。</p> * 结算合计费用项行取结算单号-费用类型-费用项拼接。</p>
* *
* @param settlement 正式结算单 * @param settlement 正式结算单
@@ -454,75 +395,125 @@ public class KingdeeApBillSyncSupport {
} }
ProjectApply project = requireProject(settlement.getProjectId(), context); ProjectApply project = requireProject(settlement.getProjectId(), context);
ContractManage contract = requireContract(settlement.getContractId(), context); ContractManage contract = requireContract(settlement.getContractId(), context);
// 合同计费方案规则:费用类型/费用项 -> 计费单位 // 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用)
Map<String, String> billingUnitByRule = billingUnitByRule(contract); Map<String, BigDecimal> taxRateByRule = taxRateByRule(contract);
// 计量单位档案:单位名称 -> 计量单位编码 // 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译)
Map<String, String> unitCodeByName = measureUnitCodeByName(context); Map<String, String> feeCategoryByName = feeCategoryByName(context);
// 结算明细费用行:判断运单是否存在装卸大类费用 EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName);
// 结算明细费用行:一车多货按货物拆分的费用快照行
Map<Long, List<FormalSettlementDetailFee>> feesByDetailId = detailFeesByDetailId(settlementDetails); Map<Long, List<FormalSettlementDetailFee>> feesByDetailId = detailFeesByDetailId(settlementDetails);
List<BfiEntryGroup> entryGroups = new ArrayList<>(); List<BfiEntryGroup> entryGroups = new ArrayList<>();
for (FormalSettlementDetail settlementDetail : settlementDetails) { for (FormalSettlementDetail settlementDetail : settlementDetails) {
appendDetailEntry(entryGroups, settlement, project, settlementDetail, appendDetailEntries(entryGroups, resolveContext, settlementDetail,
feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()), billingUnitByRule, unitCodeByName); feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()));
} }
// 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复 // 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复
List<FormalSettlementSummaryFee> manualSummaryFees = listSummaryFees(settlement.getId()).stream() List<FormalSettlementSummaryFee> manualSummaryFees = listSummaryFees(settlement.getId()).stream()
.filter(summaryFee -> Integer.valueOf(1).equals(summaryFee.getManualFlag())).toList(); .filter(summaryFee -> Integer.valueOf(1).equals(summaryFee.getManualFlag())).toList();
for (FormalSettlementSummaryFee summaryFee : manualSummaryFees) { for (FormalSettlementSummaryFee summaryFee : manualSummaryFees) {
appendSummaryEntry(entryGroups, settlement, project, summaryFee, appendSummaryEntry(entryGroups, resolveContext, summaryFee,
settlementDetails.get(0).getTransportType(), billingUnitByRule, unitCodeByName); settlementDetails.get(0).getTransportType());
} }
if (entryGroups.isEmpty()) { if (entryGroups.isEmpty()) {
throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo()) throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo())
+ "]无结算明细与结算合计费用项,无法解析分录"); + "]无结算明细与结算合计费用项,无法解析分录");
} }
// 分录解析摘要(联调排查:明细条数=结算明细行+手工添加的结算合计费用项,物料/计量单位/分录标识逐条见下方分录生成日志) // 分录解析摘要(联调排查:分录数=结算明细拆分行+手动添加的结算合计费用项,物料/分录标识逐条见下方分录生成日志)
log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录", log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录",
settlement.getFormalSettlementNo(), settlementDetails.size(), settlement.getFormalSettlementNo(), settlementDetails.size(), manualSummaryFees.size(), entryGroups.size());
entryGroups.size() - settlementDetails.size(), entryGroups.size());
return entryGroups; return entryGroups;
} }
/** /** 单张结算单分录解析上下文:结算单/项目档案与合同计费方案税率、费用项档案分类映射 */
* 单条结算明细行生成一条分录:物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1), private record EntryResolveContext(FormalSettlement settlement, ProjectApply project,
* 金额取结算金额(含税),不含税取结算金额(不含税),运输货物/车号取货物名称/车号,分录标识取单据号 Map<String, BigDecimal> taxRateByRule, Map<String, String> feeCategoryByName) {
*/
private void appendDetailEntry(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project,
FormalSettlementDetail detail, List<FormalSettlementDetailFee> detailFees,
Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName) {
String sourceDesc = "结算明细[" + Func.toStr(detail.getDocumentNo()) + "]";
// 该条运单存在装卸大类费用(明细费用项或明细费用行费用项含“装卸”)时物料映射为装卸
boolean loadingFee = hasLoadingFeeItem(detail.getFeeItemsJson())
|| detailFees.stream().anyMatch(fee -> hasLoadingFeeItem(fee.getFeeItemsJson()));
String materialNumber = resolveMaterialNumber(loadingFee, project, detail.getTransportType(), sourceDesc);
// 计量单位编码:运费费用项匹配合同计费方案计费单位,再匹配计量单位档案编码
String measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, FREIGHT_FEE_ITEM, FREIGHT_FEE_TYPE, sourceDesc);
// 数量:运费费用项目取运输量,未维护兜底 1
BigDecimal quantity = detail.getTransportQuantity() != null && detail.getTransportQuantity().compareTo(BigDecimal.ZERO) > 0
? money(detail.getTransportQuantity()) : BigDecimal.ONE;
BigDecimal amount = money(detail.getSettlementAmountTax());
BigDecimal price = entryPrice(amount, quantity);
// 分录唯一标识(awrp_entry_code):结算明细单据号;未维护时兜底明细行id,避免候选键为空被金蝶拦截
String entryCode = Func.isBlank(detail.getDocumentNo()) ? String.valueOf(detail.getId()) : detail.getDocumentNo().trim();
entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, quantity, price, amount,
money(detail.getSettlementAmountNoTax()), detail.getCargoName(), detail.getVehicleNo(), settlement.getId()));
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount, money(detail.getSettlementAmountNoTax()));
} }
/** /**
* 单条结算合计费用项生成一条分录:物料按装卸大类/项目业务类型映射(运输方式取结算明细首行), * 单条结算明细行(一个运单)按 货物 → 费用大类 → 税率 拆分多条分录:
* 数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接 * 一车多货(存在货物费用行)先按货物拆分,每个货物费用行再按费用大类/税率拆行;
* 无货物费用行时结算明细行本身即唯一货物
*/ */
private void appendSummaryEntry(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project, private void appendDetailEntries(List<BfiEntryGroup> entryGroups, EntryResolveContext context,
FormalSettlementSummaryFee summaryFee, String fallbackTransportType, FormalSettlementDetail detail, List<FormalSettlementDetailFee> detailFees) {
Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName) { if (Func.isEmpty(detailFees)) {
appendCargoLines(entryGroups, context, detail, detail.getCargoName(), detail.getFeeItemsJson(),
detail.getTransportQuantity(), money(detail.getSettlementAmountTax()), detail.getSettlementAmountNoTax(), 1);
return;
}
int cargoSeq = 0;
for (FormalSettlementDetailFee cargoFee : detailFees) {
appendCargoLines(entryGroups, context, detail, cargoFee.getCargoName(), cargoFee.getFeeItemsJson(),
cargoFee.getTransportQuantity(), money(cargoFee.getSettlementAmountTax()), cargoFee.getSettlementAmountNoTax(), ++cargoSeq);
}
}
/**
* 单个货物费用行按费用大类/税率拆分生成分录:
* 费用项名称含“装卸”归装卸大类、其余归运输大类,各大类内按合同计费方案税率分组、不同税率分行;
* 运输类每行数量取运单运输量、装卸类每行数量默认 1;
* 运输类首行携带行不含税金额(拆分行无分类不含税金额,仅保证不含税合计勾稽);
* 运输货物取货物名称、车号取结算明细车号(同一车牌号就是一个车)
*/
private void appendCargoLines(List<BfiEntryGroup> entryGroups, EntryResolveContext context,
FormalSettlementDetail detail, String cargoName, String feeItemsJson,
BigDecimal transportQuantity, BigDecimal rowAmount, BigDecimal rowAmountNoTax, int cargoSeq) {
String documentNo = entryDocumentNo(detail);
String sourceDesc = "结算明细[" + documentNo + "-" + (Func.isBlank(cargoName) ? String.valueOf(cargoSeq) : cargoName) + "]";
List<FeeLineSplit> splits = splitFeeItems(parseFeeItems(feeItemsJson), context.feeCategoryByName(),
context.taxRateByRule(), rowAmount);
if (splits.isEmpty()) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "无费用项金额,无法拆分明细分录");
}
// 运输类数量:取运单运输量(未维护兜底 1)
BigDecimal freightQuantity = transportQuantity != null && transportQuantity.compareTo(BigDecimal.ZERO) > 0
? money(transportQuantity) : BigDecimal.ONE;
String vehicleNo = detail.getVehicleNo();
// 分录唯一标识前缀(awrp_entry_code):单据号-货物序号,后续拼接费用大类与税率
String entryCodePrefix = documentNo + "-" + cargoSeq;
boolean amountNoTaxSent = false;
for (FeeLineSplit split : splits) {
String category = split.isLoading() ? "装卸" : "运输费";
// 物料:装卸大类映射装卸物料;运输类按项目业务模式/运单运输方式映射
String materialNumber = resolveMaterialNumber(split.isLoading(), context.project(), detail.getTransportType(), sourceDesc);
// 数量:运输类取运输量,装卸类默认 1
BigDecimal quantity = split.isLoading() ? BigDecimal.ONE : freightQuantity;
BigDecimal amount = money(split.getAmount());
BigDecimal price = entryPrice(amount, quantity);
// 不含税金额:仅运输类首行携带,保证不含税合计与结算明细勾稽
BigDecimal amountNoTax = null;
if (!split.isLoading() && !amountNoTaxSent && rowAmountNoTax != null) {
amountNoTax = money(rowAmountNoTax);
amountNoTaxSent = true;
}
// 分录唯一标识:单据号-货物序号-费用大类-税率(税率未配置不拼)
String entryCode = entryCodePrefix + "-" + category + split.taxRateSuffix();
entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount,
amountNoTax, cargoName, vehicleNo, context.settlement().getId()));
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]",
sourceDesc, entryCode, materialNumber, quantity, price, amount, amountNoTax);
}
}
/** 结算明细分录标识单据号:单据号为空时兜底明细行id,避免候选键为空被金蝶拦截 */
private String entryDocumentNo(FormalSettlementDetail detail) {
return Func.isBlank(detail.getDocumentNo()) ? String.valueOf(detail.getId()) : detail.getDocumentNo().trim();
}
/**
* 单条手动添加的结算合计费用项生成一条分录:非运费类(装卸/仓储等杂费)映射装卸物料,
* 运费类按项目业务模式/运输方式映射(运输方式取结算明细首行);
* 非运费类数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接
*/
private void appendSummaryEntry(List<BfiEntryGroup> entryGroups, EntryResolveContext context,
FormalSettlementSummaryFee summaryFee, String fallbackTransportType) {
FormalSettlement settlement = context.settlement();
String feeType = Func.toStr(summaryFee.getFeeType()).trim(); String feeType = Func.toStr(summaryFee.getFeeType()).trim();
String feeItem = Func.toStr(summaryFee.getFeeItem()).trim(); String feeItem = Func.toStr(summaryFee.getFeeItem()).trim();
String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]"; String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]";
boolean loadingFee = feeType.contains("装卸") || feeItem.contains("装卸"); // 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料
String materialNumber = resolveMaterialNumber(loadingFee, project, fallbackTransportType, sourceDesc); boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName());
String measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, feeItem, feeType, sourceDesc); String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc);
// 结算合计费用项行无运输量,数量默认 1 // 结算合计费用项行无运输量,数量默认 1
BigDecimal quantity = BigDecimal.ONE; BigDecimal quantity = BigDecimal.ONE;
BigDecimal amount = money(summaryFee.getSettlementAmount()); BigDecimal amount = money(summaryFee.getSettlementAmount());
@@ -530,34 +521,43 @@ public class KingdeeApBillSyncSupport {
// 分录唯一标识(awrp_entry_code):结算单号-费用类型-费用项(费用项为空时仅拼结算单号-费用类型) // 分录唯一标识(awrp_entry_code):结算单号-费用类型-费用项(费用项为空时仅拼结算单号-费用类型)
String entryCode = settlement.getFormalSettlementNo() + "-" String entryCode = settlement.getFormalSettlementNo() + "-"
+ (Func.isBlank(feeItem) ? feeType : feeType + "-" + feeItem); + (Func.isBlank(feeItem) ? feeType : feeType + "-" + feeItem);
entryGroups.add(new BfiEntryGroup(materialNumber, measureunitNumber, entryCode, quantity, price, amount, entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount,
null, null, null, settlement.getId())); null, null, null, settlement.getId()));
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}]", log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}]",
sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount); sourceDesc, entryCode, materialNumber, quantity, price, amount);
} }
/** /**
* 物料编码映射:装卸大类费用映射“装卸”;业务模式/项目业务类型含“国际”(国际运输、国际货物运输代理服务) * 物料编码映射(Nacos 配置 thirdparty.bfi.material.*):
* 映射“国际货物运输代理服务”;其余(国内运输/国内道路货物运输服务)拼接运单运输方式, * 装卸大类(及手动添加的非运费杂费)映射装卸物料 loading;业务模式/项目业务类型含“国际”(国际运输、
* 如“国内道路货物运输服务-水路” * 国际货物运输代理服务)映射国际货代服务 international;国内运输按运单运输方式查 domestic 映射
* (公路运输/铁路运输/水路运输/航空运输),运输方式未配置映射时直接拦截报错
* *
* @param loadingFee 该行是否属于装卸大类费用 * @param loadingFee 该行是否属于装卸大类(或非运费类杂费)
* @param project 结算单关联项目(业务类型/业务模式取数来源) * @param project 结算单关联项目(业务类型/业务模式取数来源)
* @param transportType 运单运输方式(结算合计费用项行取结算明细首行运输方式) * @param transportType 运单运输方式(结算合计费用项行取结算明细首行运输方式)
* @param sourceDesc 来源行描述,用于异常提示 * @param sourceDesc 来源行描述,用于异常提示
* @return 金蝶物料编码(按映射规则拼出的物料名称) * @return 金蝶物料编码
*/ */
private String resolveMaterialNumber(boolean loadingFee, ProjectApply project, String transportType, String sourceDesc) { private String resolveMaterialNumber(boolean loadingFee, ProjectApply project, String transportType, String sourceDesc) {
BfiProperties.Material material = bfiProperties.getMaterial();
String materialNumber; String materialNumber;
if (loadingFee) { if (loadingFee) {
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_LOADING; materialNumber = material.getLoading();
} else if (isInternationalBusiness(project)) { } else if (isInternationalBusiness(project)) {
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_INTERNATIONAL; materialNumber = material.getInternational();
} else { } else {
if (Func.isBlank(transportType)) { if (Func.isBlank(transportType)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "运单运输方式为空,无法拼接国内道路货物运输服务物料,请先维护运输方式"); throw new ServiceException("推送金蝶失败:" + sourceDesc + "运单运输方式为空,无法匹配国内运输物料,请先维护运输方式");
} }
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_DOMESTIC_PREFIX + transportType.trim(); materialNumber = material.getDomestic().get(transportType.trim());
if (Func.isBlank(materialNumber)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "运输方式[" + transportType.trim()
+ "]未在配置 thirdparty.bfi.material.domestic 中维护物料编码,请补充后重试");
}
}
if (Func.isBlank(materialNumber)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在配置 thirdparty.bfi.material 中维护对应物料编码,请补充后重试");
} }
// 物料映射判定依据(联调排查:装卸大类/业务模式/项目业务类型/运输方式 -> 物料) // 物料映射判定依据(联调排查:装卸大类/业务模式/项目业务类型/运输方式 -> 物料)
log.info("金蝶物料映射:来源[{}],装卸大类[{}],业务模式[{}],项目业务类型[{}],运单运输方式[{}] -> 物料编码[{}]", log.info("金蝶物料映射:来源[{}],装卸大类[{}],业务模式[{}],项目业务类型[{}],运单运输方式[{}] -> 物料编码[{}]",
@@ -580,102 +580,215 @@ public class KingdeeApBillSyncSupport {
} }
/** /**
* 计量单位编码解析:费用项优先、费用类型兜底匹配合同计费方案规则计费单位, * 合同计费方案规则税率解析:费用类型/费用项 -> 税率(取首个配置了税率的规则,
* 再按计量单位档案(单位名称匹配)取计量单位编码,任一环节缺失直接拦截报错 * 计费方案 JSON 解析失败按未配置处理,由税率分组处兜底为无税率组)
*
* @param billingUnitByRule 合同计费方案:费用类型/费用项 -> 计费单位
* @param unitCodeByName 计量单位档案:单位名称 -> 计量单位编码
* @param feeItem 费用项目(结算合计费用项行取行费用项,结算明细行固定运输费)
* @param feeType 费用类型(结算合计费用项行取行费用类型,结算明细行固定物流配送)
* @param sourceDesc 来源行描述,用于异常提示
* @return 计量单位编码
*/
private String resolveMeasureunitNumber(Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName,
String feeItem, String feeType, String sourceDesc) {
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)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在合同计费方案中配置计费单位,请先维护计费方案规则");
}
String unitCode = unitCodeByName.get(billingUnit.trim());
if (Func.isBlank(unitCode)) {
throw new ServiceException("推送金蝶失败:" + sourceDesc + "计费单位[" + billingUnit + "]未在计量单位档案中维护编码,请补充后重试");
}
// 计量单位匹配链路(联调排查:费用项/费用类型 -> 合同计费方案计费单位 -> 计量单位档案编码)
log.info("金蝶计量单位匹配:来源[{}],费用项[{}],费用类型[{}] -> 计费单位[{}] -> 计量单位编码[{}]",
sourceDesc, feeItem, feeType, billingUnit, unitCode);
return unitCode;
}
/**
* 合同计费方案规则解析:费用类型/费用项 -> 计费单位(取首个配置了计费单位的规则,
* 计费方案 JSON 解析失败按未配置处理,由计量单位解析处统一拦截报错)
* *
* @param contract 结算合同 * @param contract 结算合同
* @return 费用类型/费用项 -> 计费单位映射 * @return 费用类型/费用项 -> 税率(百分数)映射
*/ */
private Map<String, String> billingUnitByRule(ContractManage contract) { private Map<String, BigDecimal> taxRateByRule(ContractManage contract) {
Map<String, String> mapping = new HashMap<>(); Map<String, BigDecimal> taxRateByRule = new HashMap<>();
if (Func.isBlank(contract.getBillingPlanJson())) { if (Func.isBlank(contract.getBillingPlanJson())) {
return mapping; return taxRateByRule;
} }
try { try {
Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class); Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class);
if (!(parsed instanceof List<?> plans)) { if (parsed instanceof List<?> plans) {
return mapping; for (Object planValue : plans) {
} if (!(planValue instanceof Map<?, ?> plan) || !(plan.get("rules") instanceof List<?> rules)) {
for (Object planValue : plans) {
if (!(planValue instanceof Map<?, ?> plan) || !(plan.get("rules") instanceof List<?> rules)) {
continue;
}
for (Object ruleValue : rules) {
if (!(ruleValue instanceof Map<?, ?> rule)) {
continue; continue;
} }
String billingUnit = textValue(rule.get("billingUnit")); for (Object ruleValue : rules) {
if (Func.isBlank(billingUnit)) { if (!(ruleValue instanceof Map<?, ?> rule)) {
continue; continue;
} }
String feeItem = textValue(rule.get("feeItem")); BigDecimal taxRate = taxRateValue(rule.get("taxRate"));
String feeType = textValue(rule.get("feeType")); if (taxRate == null) {
if (Func.isNotBlank(feeItem)) { continue;
mapping.putIfAbsent(feeItem, billingUnit); }
} String feeItem = textValue(rule.get("feeItem"));
if (Func.isNotBlank(feeType)) { String feeType = textValue(rule.get("feeType"));
mapping.putIfAbsent(feeType, billingUnit); if (Func.isNotBlank(feeItem)) {
taxRateByRule.putIfAbsent(feeItem, taxRate);
}
if (Func.isNotBlank(feeType)) {
taxRateByRule.putIfAbsent(feeType, taxRate);
}
} }
} }
} }
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
// 计费方案 JSON 兼容历史数据,解析失败按未配置处理 // 计费方案 JSON 兼容历史数据,解析失败按未配置处理
} }
log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置计费单位{}", contract.getContractNo(), mapping.size(), mapping); log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置税率{}", contract.getContractNo(), taxRateByRule.size(), taxRateByRule);
return mapping; return taxRateByRule;
} }
/** 费用项 JSON 是否包含装卸大类费用(费用项名称含“装卸”) */ /** 合同计费方案规则税率:百分数文本转数值(如 9 表示 9%),格式非法按未配置处理 */
private boolean hasLoadingFeeItem(String feeItemsJson) { private BigDecimal taxRateValue(Object value) {
if (value == null) {
return null;
}
try {
return new BigDecimal(String.valueOf(value).trim());
} catch (NumberFormatException exception) {
return null;
}
}
/**
* 费用项按费用大类/税率拆分(纯函数):
* 费用项经费用项档案/业务字典判为装卸大类(名称含“装卸”的费用类型)、其余归运输大类;
* 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组),
* 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽
*
* @param feeItems 费用项名称 -> 金额(费用项JSON解析结果,保持行序)
* @param feeCategoryByName 费用项档案:费用项名称 -> 费用类型名称(空 Map 时回退名称关键字判定)
* @param taxRateByRule 合同计费方案:费用类型/费用项 -> 税率(百分数)
* @param rowAmount 货物费用行结算金额(含税),残差勾稽基准
* @return 拆分行列表(保持费用项出现顺序)
*/
public static List<FeeLineSplit> splitFeeItems(Map<String, BigDecimal> feeItems, Map<String, String> feeCategoryByName,
Map<String, BigDecimal> taxRateByRule, BigDecimal rowAmount) {
Map<String, FeeLineSplit> grouped = new LinkedHashMap<>();
for (Map.Entry<String, BigDecimal> item : feeItems.entrySet()) {
if (Func.isBlank(item.getKey())) {
continue;
}
BigDecimal amount = item.getValue() == null ? BigDecimal.ZERO : item.getValue();
boolean loading = isLoadingCategory(item.getKey(), feeCategoryByName);
BigDecimal taxRate = taxRateByRule == null ? null : taxRateByRule.get(item.getKey());
FeeLineSplit split = grouped.get(splitGroupKey(loading, taxRate));
if (split == null) {
grouped.put(splitGroupKey(loading, taxRate), new FeeLineSplit(loading, taxRate, amount, item.getKey()));
} else {
split.addAmount(amount);
}
}
// 残差(结算金额 - 费用项合计,如费用项JSON未含运输费的运费金额)并入运输类无税率组,保证合计勾稽
BigDecimal residual = (rowAmount == null ? BigDecimal.ZERO : rowAmount).subtract(grouped.values().stream()
.map(FeeLineSplit::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add));
if (residual.signum() != 0) {
FeeLineSplit freightDefault = grouped.computeIfAbsent(splitGroupKey(false, null),
key -> new FeeLineSplit(false, null, BigDecimal.ZERO, FREIGHT_FEE_ITEM));
freightDefault.addAmount(residual);
}
return new ArrayList<>(grouped.values());
}
/** 费用大类/税率分组键(装卸大类与运输大类、税率去重分组) */
private static String splitGroupKey(boolean loading, BigDecimal taxRate) {
return (loading ? "装卸" : "运输费") + "@" + (taxRate == null ? "" : taxRate.stripTrailingZeros().toPlainString());
}
/** 装卸大类费用项判定(关键字兜底):费用项名称含“装卸” */
static boolean isLoadingFeeItem(String feeItem) {
return feeItem != null && feeItem.contains("装卸");
}
/**
* 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用类型名称(业务字典 fee_category 翻译)
* 含“装卸”归装卸大类;档案未维护该费用项时回退费用项名称关键字判定
*/
static boolean isLoadingCategory(String feeItemName, Map<String, String> feeCategoryByName) {
String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
if (Func.isNotBlank(categoryName)) {
return categoryName.contains("装卸");
}
return isLoadingFeeItem(feeItemName);
}
/** 运费类费用判定(关键字兜底):名称含“运费”或“运输费” */
static boolean isFreightFeeText(String name) {
return name != null && (name.contains("运费") || name.contains("运输费"));
}
/**
* 运费类判定(手动添加费用行物料映射用):费用项档案分类名称、费用类型、费用项名称任一
* 含“运费/运输费”即归运费类映射运输物料;档案未维护时退化为名称关键字判定(与原口径一致)
*/
static boolean isFreightCategory(String feeTypeName, String feeItemName, Map<String, String> feeCategoryByName) {
String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName);
return isFreightFeeText(categoryName) || isFreightFeeText(feeTypeName) || isFreightFeeText(feeItemName);
}
/**
* 费用项JSON解析:费用项名称 -> 金额(保持行序,解析失败/非法金额按空处理,
* 金额缺失由运输类无税率组残差兜底)
*/
private Map<String, BigDecimal> parseFeeItems(String feeItemsJson) {
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
if (Func.isEmpty(feeItemsJson)) { if (Func.isEmpty(feeItemsJson)) {
return false; return feeItems;
} }
try { try {
Object parsed = JsonUtil.parse(feeItemsJson, Map.class); Object parsed = JsonUtil.parse(feeItemsJson, Map.class);
if (parsed instanceof Map<?, ?> map) { if (parsed instanceof Map<?, ?> map) {
return map.keySet().stream().map(String::valueOf).anyMatch(key -> key.contains("装卸")); for (Map.Entry<?, ?> item : map.entrySet()) {
if (item.getKey() == null || item.getValue() == null) {
continue;
}
try {
feeItems.put(String.valueOf(item.getKey()), new BigDecimal(String.valueOf(item.getValue()).trim()));
} catch (NumberFormatException ignored) {
// 兼容历史费用JSON的非法金额
}
}
} }
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
// 兼容历史费用JSON // 兼容历史费用JSON
} }
return false; return feeItems;
}
/**
* 费用大类/税率拆分行:装卸大类 loading(true=装卸)、税率 taxRate(百分数,未配置为 null)、
* 组内金额合计 amount、组内首个费用项 firstFeeItem
*/
public static final class FeeLineSplit {
private final boolean loading;
private final BigDecimal taxRate;
private final String firstFeeItem;
private BigDecimal amount;
FeeLineSplit(boolean loading, BigDecimal taxRate, BigDecimal amount, String firstFeeItem) {
this.loading = loading;
this.taxRate = taxRate;
this.amount = amount;
this.firstFeeItem = firstFeeItem;
}
void addAmount(BigDecimal value) {
amount = amount.add(value);
}
public boolean isLoading() {
return loading;
}
public BigDecimal getTaxRate() {
return taxRate;
}
public BigDecimal getAmount() {
return amount;
}
public String getFirstFeeItem() {
return firstFeeItem;
}
/** 分录标识税率后缀:税率未配置返回空串,配置返回“-税率”(如 -9、-6.5) */
public String taxRateSuffix() {
return taxRate == null ? "" : "-" + taxRate.stripTrailingZeros().toPlainString();
}
} }
private String textValue(Object value) { private String textValue(Object value) {
return value == null ? null : String.valueOf(value).trim(); return value == null ? null : String.valueOf(value).trim();
} }
/** 结算明细id -> 结算明细费用行(判断运单是否存在装卸大类费用) */ /** 结算明细id -> 结算明细费用行(一车多货按货物拆分的费用快照行) */
private Map<Long, List<FormalSettlementDetailFee>> detailFeesByDetailId(List<FormalSettlementDetail> settlementDetails) { private Map<Long, List<FormalSettlementDetailFee>> detailFeesByDetailId(List<FormalSettlementDetail> settlementDetails) {
List<Long> detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId) List<Long> detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId)
.filter(Objects::nonNull).distinct().toList(); .filter(Objects::nonNull).distinct().toList();
@@ -698,26 +811,30 @@ public class KingdeeApBillSyncSupport {
} }
/** /**
* 计量单位档案:单位名称 -> 计量单位编码(批量同步时全批共享一次加载结果; * 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典
* 应收单同步组件复用) * {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用;
* 档案读取失败直接拦截
*/ */
public Map<String, String> measureUnitCodeByName(KingdeeSyncContext context) { public Map<String, String> feeCategoryByName(KingdeeSyncContext context) {
Map<String, String> cache = context.measureUnitCodeByName(); Map<String, String> cache = context.feeCategoryByName();
if (!cache.isEmpty()) { if (!cache.isEmpty()) {
return cache; return cache;
} }
R<List<MeasurementUnit>> response = sysClient.getMeasurementUnits(); Map<String, String> categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream()
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first));
R<List<FeeItem>> response = sysClient.getFeeItems();
if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) {
throw new ServiceException("推送金蝶失败:计量单位档案读取失败,请稍后重试"); throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试");
} }
Map<String, String> loaded = new HashMap<>(); Map<String, String> loaded = new HashMap<>();
for (MeasurementUnit measurementUnit : response.getData()) { for (FeeItem feeItem : response.getData()) {
if (measurementUnit == null || Func.isBlank(measurementUnit.getUnitName()) || Func.isBlank(measurementUnit.getUnitCode())) { if (feeItem == null || Func.isBlank(feeItem.getName()) || Func.isBlank(feeItem.getFeeCategory())) {
continue; continue;
} }
loaded.putIfAbsent(measurementUnit.getUnitName().trim(), measurementUnit.getUnitCode().trim()); loaded.putIfAbsent(feeItem.getName().trim(),
categoryNames.getOrDefault(feeItem.getFeeCategory().trim(), feeItem.getFeeCategory().trim()));
} }
log.info("计量单位档案加载:{}项(单位名称 -> 金蝶计量单位编码)", loaded.size()); log.info("费用项档案加载:{}项(费用项名称 -> 费用类型名称)", loaded.size());
cache.putAll(loaded); cache.putAll(loaded);
return cache; return cache;
} }
@@ -895,12 +1012,11 @@ public class KingdeeApBillSyncSupport {
} }
/** /**
* 财务应付单/付款申请分录:结算明细行 + 结算合计费用项 各一条(不聚合) * 财务应付单/付款申请分录:结算明细按 货物→费用大类→税率 拆分行 + 结算合计费用项(手动添加的每行一条)
* *
* @param materialNumber 物料编码(按项目业务类型/业务模式/装卸大类映射) * @param materialNumber 物料编码(按装卸大类/项目业务模式/运单运输方式映射,thirdparty.bfi.material.* 配置)
* @param measureunitNumber 计量单位编码(费用类型匹配合同计费方案计费单位,再匹配计量单位档案) * @param entryCode 分录唯一标识(awrp_entry_code):结算明细拆分行取单据号-货物序号-大类-税率,结算合计费用项行取结算单号-费用类型-费用项
* @param entryCode 分录唯一标识(awrp_entry_code):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项 * @param quantity 该行分录数量(运输类取运输量,装卸类/其它默认 1)
* @param quantity 该行分录数量(运费费用项目取运输量,其它费用项目默认 1)
* @param price 该行分录单价(结算金额/数量反算) * @param price 该行分录单价(结算金额/数量反算)
* @param amount 该行金额(含税),对应明细应付金额 e_pricetaxtotal * @param amount 该行金额(含税),对应明细应付金额 e_pricetaxtotal
* @param amountNoTax 该行金额(不含税),对应明细金额 e_amount;结算合计费用项行无不含税金额为 null 不传 * @param amountNoTax 该行金额(不含税),对应明细金额 e_amount;结算合计费用项行无不含税金额为 null 不传
@@ -908,7 +1024,7 @@ public class KingdeeApBillSyncSupport {
* @param vehicleNo 车号(金蝶接口新字段 awrp_lpn):结算明细车号,结算合计费用项行为 null 不传 * @param vehicleNo 车号(金蝶接口新字段 awrp_lpn):结算明细车号,结算合计费用项行为 null 不传
* @param formalSettlementId 分录所属正式结算单id(多结算单付款申请 A103-1 按结算单勾稽各自应付单) * @param formalSettlementId 分录所属正式结算单id(多结算单付款申请 A103-1 按结算单勾稽各自应付单)
*/ */
public record BfiEntryGroup(String materialNumber, String measureunitNumber, String entryCode, public record BfiEntryGroup(String materialNumber, String entryCode,
BigDecimal quantity, BigDecimal price, BigDecimal amount, BigDecimal amountNoTax, BigDecimal quantity, BigDecimal price, BigDecimal amount, BigDecimal amountNoTax,
String transportGoods, String vehicleNo, Long formalSettlementId) { String transportGoods, String vehicleNo, Long formalSettlementId) {
} }
@@ -144,7 +144,7 @@ public class KingdeeArBillSyncSupport {
* 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。 * 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。
* 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时 * 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
* 明细分录与应付单(A102-2)同源同口径:页面结算明细(每行一条)+ 结算合计费用项(每行一条); * 明细分录与应付单(A102-2)同源同口径:页面结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项(每行一条);
* 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成 * 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成
* *
* @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接) * @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接)
@@ -225,18 +225,14 @@ public class KingdeeArBillSyncSupport {
param.setExchangerate(settlement.getExchangeRate()); param.setExchangerate(settlement.getExchangeRate());
} }
// 明细分录:与应付单同源同口径——结算明细行 + 结算合计费用项各一条(不聚合),无吨位时数量兜底 1 // 明细分录:与应付单同源同口径——结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条,无吨位时数量兜底 1
List<BfiArBillSaveEntryDTO> entry = new ArrayList<>(); List<BfiArBillSaveEntryDTO> entry = new ArrayList<>();
for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) {
BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO(); BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO();
String materialNumber = entryGroup.materialNumber(); // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置)
row.setEMaterialNumber(entryGroup.materialNumber());
// 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源) // 数量:运输类取运输量,装卸类/其它默认 1
row.setEMaterialNumber(materialNumber);
// 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码
row.setEMeasureunitNumber(entryGroup.measureunitNumber());
// 【必填】数量:运费费用项目取运输量,其它费用项目默认 1
row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传 // 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传
row.setAwrpTransport(entryGroup.transportGoods()); row.setAwrpTransport(entryGroup.transportGoods());
@@ -256,34 +252,6 @@ public class KingdeeArBillSyncSupport {
// 分录备注(非必传) // 分录备注(非必传)
// row.setERemark(subject); // row.setERemark(subject);
// 调试:物料编码和计量单位编码映射(4个固定组合)
if (true) {
// 物料+单位组合映射,只保留【已审核】物料
Map<String, String[]> comboMap = Map.of(
"国际货运代理服务费", new String[]{"Item-00001081", "ci"},
"物流配送", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-吨", new String[]{"XDWL11010001", "TON"},
"国内道路货物运输服务-公路运输-公斤", new String[]{"XDWL11010002", "KG"}
);
// ⚠️兜底改成【已审核】物流配送 Item-00001080 + TON
String[] defaultCombo = comboMap.get("物流配送");
// 构建组合键:物料名称-计量单位
String measureunitName = entryGroup.measureunitNumber();
String comboKey = materialNumber;
if (materialNumber.contains("国内道路货物运输服务")) {
comboKey = materialNumber + "-" + measureunitName;
}
// 匹配组合,不匹配使用兜底组合
String[] combo = comboMap.getOrDefault(comboKey, defaultCombo);
materialNumber = combo[0];
row.setEMaterialNumber(materialNumber);
row.setEMeasureunitNumber(combo[1]);
}
entry.add(row); entry.add(row);
} }
param.setEntry(entry); param.setEntry(entry);
@@ -306,7 +274,7 @@ public class KingdeeArBillSyncSupport {
/** /**
* 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径—— * 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径——
* 页面结算明细每行一条 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送), * 页面结算明细按 货物→费用大类→税率 拆分行 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送),
* 物料/计量单位/数量/单价/金额/分录标识取数规则一致, * 物料/计量单位/数量/单价/金额/分录标识取数规则一致,
* 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税)) * 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税))
* *
@@ -149,7 +149,7 @@ public class KingdeeReceivedInvoiceSyncSupport {
/** /**
* 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。 * 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。
* 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位; * 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位;
* 明细分录与财务应付单 A102-2 同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/金额取自 * 明细分录与财务应付单 A102-2 同源同口径:结算明细按 货物→费用大类→税率 拆分行+手动添加结算合计费用项每行一条,物料/数量/单价/金额取自
* 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。 * 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。
* 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码), * 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码),
* 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开 * 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开
@@ -232,7 +232,8 @@ public class KingdeeReceivedInvoiceSyncSupport {
} }
/** /**
* 分录组装与应付单 A102-2 明细同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/含税/不含税金额 * 分录组装与应付单 A102-2 明细同源同口径:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条,
* 物料/数量/单价/含税/不含税金额
* 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键, * 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键,
* 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1; * 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1;
* 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补) * 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补)
@@ -250,32 +251,8 @@ public class KingdeeReceivedInvoiceSyncSupport {
entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
// 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考) // 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考)
entry.setPrice(entryGroup.price()); entry.setPrice(entryGroup.price());
// 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源) // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置,与应付单同源)
String materialNumber = entryGroup.materialNumber(); entry.setMaterialNumber(entryGroup.materialNumber());
// 调试:物料编码和计量单位编码映射(收票单场景)
if (true) {
Map<String, String[]> comboMap = Map.of(
"国际货运代理服务费", new String[]{"Item-00001081", "ci"},
"物流配送", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-吨", new String[]{"Item-00001080", "TON"},
"国内道路货物运输服务-公路运输-公斤", new String[]{"Item-00001080", "KG"},
"装卸", new String[]{"Item-00001080", "KG"}
);
String measureunitName = entryGroup.measureunitNumber();
String comboKey = materialNumber;
if (materialNumber.contains("国内道路货物运输服务")) {
comboKey = materialNumber + "-" + measureunitName;
}
if (comboMap.containsKey(comboKey)) {
String[] combo = comboMap.get(comboKey);
materialNumber = combo[0];
} else {
List<String[]> combos = new ArrayList<>(comboMap.values());
String[] randomCombo = combos.get(new java.util.Random().nextInt(combos.size()));
materialNumber = randomCombo[0];
}
}
entry.setMaterialNumber(materialNumber);
// 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传) // 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传)
if (entryGroup.amountNoTax() != null) { if (entryGroup.amountNoTax() != null) {
entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax())); entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax()));