feat: 应付应收收票单分录按货物-费用大类-税率拆分并按配置映射金蝶物料编码
结算明细解析收口至应付支撑组件共享:一车多货按货物拆、费用大类(费用项档案+fee_category字典判定,关键字兜底)拆、合同计费方案税率拆行(运输类每行取运输量、装卸类数量1);残差并入运输类无税率组保证金额勾稽;手动添加结算合计费用每行单独一条(非运费类映射装卸物料);物料编码/计量单位映射调整:物料编码收口 Nacos thirdparty.bfi.material.* 配置(去兜底),计量单位非必填不再解析传参;移除三处联调调试块与废弃物料常量
This commit is contained in:
+39
@@ -30,6 +30,9 @@ import org.springblade.thirdparty.bfi.constant.BfiConstant;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 业财融合(BFI)推送金蝶配置属性,统一收口 Nacos 配置 thirdParty.bfi.*:
|
||||
* <p>
|
||||
@@ -70,6 +73,13 @@ public class BfiProperties {
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分录物料映射配置项(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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-21
@@ -59,27 +59,6 @@ public final class KingdeeBfiConstant {
|
||||
/** 联调默认项目编码:金蝶测试档案值,项目档案与金蝶对齐后替换。 */
|
||||
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";
|
||||
|
||||
|
||||
+5
-4
@@ -40,16 +40,17 @@ import java.util.Map;
|
||||
*
|
||||
* @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> apBillNoCache,
|
||||
Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
|
||||
Map<Long, FormalSettlement> settlementCache,
|
||||
Map<Long, CustomerReceiptAccount> receiptAccountCache) {
|
||||
Map<Long, CustomerReceiptAccount> receiptAccountCache,
|
||||
Map<String, String> feeCategoryByName) {
|
||||
|
||||
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
|
||||
public static KingdeeSyncContext empty() {
|
||||
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(),
|
||||
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<>());
|
||||
}
|
||||
}
|
||||
|
||||
+335
-219
@@ -32,10 +32,12 @@ 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.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.BfiApBillSaveDTO;
|
||||
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-2 保存、结算明细分录解析与档案取数统一收口在本组件;
|
||||
* 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。</p>
|
||||
* <p>明细分录口径:取正式结算单页面结算明细(每行一条)+ 结算合计费用项(每行一条);
|
||||
* 物料编码按项目业务类型/业务模式/装卸大类映射(见 {@link #resolveMaterialNumber});
|
||||
* 计量单位编码按费用类型匹配合同计费方案规则计费单位,再匹配计量单位档案编码;
|
||||
* 数量运费费用项目取运输量、其余默认 1;单价按结算金额/数量反算。</p>
|
||||
* <p>明细分录口径:取正式结算单页面结算明细(每行一个运单,按 货物 → 费用大类 → 税率 拆分多行)
|
||||
* + 结算合计费用项(手动添加的每行一条);
|
||||
* 物料编码按装卸大类/项目业务模式/运单运输方式映射,编码维护在 Nacos 配置 thirdparty.bfi.material.*(见 {@link #resolveMaterialNumber});
|
||||
* 运输类分录数量取运输量(按税率拆行时每行都取运输量)、装卸类默认 1;单价按结算金额/数量反算。</p>
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -98,10 +100,10 @@ public class KingdeeApBillSyncSupport {
|
||||
private static final int PAY_RESULT_QUERY_BATCH = 50;
|
||||
/** A102-4 分页查询页大小 */
|
||||
private static final int PAY_RESULT_PAGE_SIZE = 200;
|
||||
/** 结算明细行(运费)计费方案匹配费用项 */
|
||||
/** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */
|
||||
private static final String FREIGHT_FEE_ITEM = "运输费";
|
||||
/** 结算明细行(运费)计费方案匹配费用类型 */
|
||||
private static final String FREIGHT_FEE_TYPE = "物流配送";
|
||||
/** 业务字典:费用类型(fee_category),费用项档案分类编码 -> 名称 */
|
||||
private static final String FEE_CATEGORY_DICT = "fee_category";
|
||||
|
||||
private final IBfiService bfiService;
|
||||
private final ContractManageMapper contractManageMapper;
|
||||
@@ -204,7 +206,7 @@ public class KingdeeApBillSyncSupport {
|
||||
* 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。
|
||||
* 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时
|
||||
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
|
||||
* 明细分录取页面结算明细(每行一条)+ 结算合计费用项(每行一条),结算金额逐行直送;
|
||||
* 明细分录取页面结算明细(每行按 货物→费用大类→税率 拆分)+ 结算合计费用项(每行一条),结算金额逐行直送;
|
||||
* 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释),
|
||||
* 当前由金蝶按明细自动汇总生成
|
||||
*
|
||||
@@ -291,23 +293,17 @@ public class KingdeeApBillSyncSupport {
|
||||
// 【必填】外部单据类型
|
||||
param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE);
|
||||
|
||||
// 明细分录:结算明细行 + 结算合计费用项各一条(不聚合)
|
||||
// 明细分录:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条
|
||||
List<BfiApBillSaveEntryDTO> detailentry = new ArrayList<>();
|
||||
for (BfiEntryGroup entryGroup : entryGroups) {
|
||||
BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO();
|
||||
// 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(国内道路货物运输服务-运输方式 / 国际货物运输代理服务 / 装卸)
|
||||
String materialNumber = entryGroup.materialNumber();
|
||||
|
||||
|
||||
|
||||
entry.setMaterialNumber(materialNumber);
|
||||
// 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置)
|
||||
entry.setMaterialNumber(entryGroup.materialNumber());
|
||||
// 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传
|
||||
entry.setAwrpTransport(entryGroup.transportGoods());
|
||||
// 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传
|
||||
entry.setAwrpLpn(entryGroup.vehicleNo());
|
||||
// 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码
|
||||
entry.setMeasureunitNumber(entryGroup.measureunitNumber());
|
||||
// 【必填】数量:运费费用项目取运输量,其它费用项目默认 1
|
||||
// 数量:运输类取运输量,装卸类/其它默认 1
|
||||
entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
|
||||
// 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考)
|
||||
entry.setPrice(entryGroup.price());
|
||||
@@ -322,63 +318,6 @@ public class KingdeeApBillSyncSupport {
|
||||
// 分录备注(非必传)
|
||||
// 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);
|
||||
}
|
||||
param.setDetailentry(detailentry);
|
||||
@@ -432,14 +371,16 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单张正式结算单分录:页面结算明细每行一条 + 结算合计费用项每行一条。
|
||||
* 解析单张正式结算单分录:页面结算明细每行(一个运单)按 货物 → 费用大类 → 税率 拆分多行
|
||||
* + 手动添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送)。
|
||||
* <p>取数口径:
|
||||
* 物料编码按项目业务类型/业务模式/装卸大类映射——装卸大类费用行映射“装卸”;国际运输业务模式
|
||||
* (或国际货物运输代理服务项目业务类型)映射“国际货物运输代理服务”;国内映射“国内道路货物运输服务-”+运单运输方式;
|
||||
* 计量单位编码按费用类型/费用项匹配合同计费方案规则计费单位,再按计量单位档案取编码;
|
||||
* 数量运费费用项目取运输量(未维护兜底 1)、结算合计费用项默认 1;单价按结算金额/数量反算;
|
||||
* 结算明细拆行——一车多货(存在货物费用行)先按货物拆;费用项名称含“装卸”归装卸大类、其余归运输大类,
|
||||
* 各大类内按合同计费方案规则税率分组、不同税率分行,运输类每行数量取运单运输量、装卸类每行数量默认 1,
|
||||
* 费用项金额合计与结算金额的残差并入运输类无税率组保证勾稽;
|
||||
* 物料编码按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置);
|
||||
* 单价按结算金额/数量反算;
|
||||
* 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额;
|
||||
* 运输货物/车号仅结算明细行传(货物名称/车号);分录标识:结算明细行取单据号、
|
||||
* 运输货物/车号仅结算明细拆分行传(货物名称/车号);分录标识:结算明细拆分行取单据号-货物序号-大类-税率、
|
||||
* 结算合计费用项行取结算单号-费用类型-费用项拼接。</p>
|
||||
*
|
||||
* @param settlement 正式结算单
|
||||
@@ -454,75 +395,125 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
ProjectApply project = requireProject(settlement.getProjectId(), context);
|
||||
ContractManage contract = requireContract(settlement.getContractId(), context);
|
||||
// 合同计费方案规则:费用类型/费用项 -> 计费单位
|
||||
Map<String, String> billingUnitByRule = billingUnitByRule(contract);
|
||||
// 计量单位档案:单位名称 -> 计量单位编码
|
||||
Map<String, String> unitCodeByName = measureUnitCodeByName(context);
|
||||
// 结算明细费用行:判断运单是否存在装卸大类费用
|
||||
// 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用)
|
||||
Map<String, BigDecimal> taxRateByRule = taxRateByRule(contract);
|
||||
// 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译)
|
||||
Map<String, String> feeCategoryByName = feeCategoryByName(context);
|
||||
EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName);
|
||||
// 结算明细费用行:一车多货按货物拆分的费用快照行
|
||||
Map<Long, List<FormalSettlementDetailFee>> feesByDetailId = detailFeesByDetailId(settlementDetails);
|
||||
List<BfiEntryGroup> entryGroups = new ArrayList<>();
|
||||
for (FormalSettlementDetail settlementDetail : settlementDetails) {
|
||||
appendDetailEntry(entryGroups, settlement, project, settlementDetail,
|
||||
feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()), billingUnitByRule, unitCodeByName);
|
||||
appendDetailEntries(entryGroups, resolveContext, settlementDetail,
|
||||
feesByDetailId.getOrDefault(settlementDetail.getId(), List.of()));
|
||||
}
|
||||
// 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复
|
||||
List<FormalSettlementSummaryFee> manualSummaryFees = listSummaryFees(settlement.getId()).stream()
|
||||
.filter(summaryFee -> Integer.valueOf(1).equals(summaryFee.getManualFlag())).toList();
|
||||
for (FormalSettlementSummaryFee summaryFee : manualSummaryFees) {
|
||||
appendSummaryEntry(entryGroups, settlement, project, summaryFee,
|
||||
settlementDetails.get(0).getTransportType(), billingUnitByRule, unitCodeByName);
|
||||
appendSummaryEntry(entryGroups, resolveContext, summaryFee,
|
||||
settlementDetails.get(0).getTransportType());
|
||||
}
|
||||
if (entryGroups.isEmpty()) {
|
||||
throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo())
|
||||
+ "]无结算明细与结算合计费用项,无法解析分录");
|
||||
}
|
||||
// 分录解析摘要(联调排查:明细条数=结算明细行+手工添加的结算合计费用项,物料/计量单位/分录标识逐条见下方分录生成日志)
|
||||
// 分录解析摘要(联调排查:分录数=结算明细拆分行+手动添加的结算合计费用项,物料/分录标识逐条见下方分录生成日志)
|
||||
log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录",
|
||||
settlement.getFormalSettlementNo(), settlementDetails.size(),
|
||||
entryGroups.size() - settlementDetails.size(), entryGroups.size());
|
||||
settlement.getFormalSettlementNo(), settlementDetails.size(), manualSummaryFees.size(), entryGroups.size());
|
||||
return entryGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条结算明细行生成一条分录:物料按项目业务类型/业务模式/装卸大类映射,数量取运输量(兜底 1),
|
||||
* 金额取结算金额(含税),不含税取结算金额(不含税),运输货物/车号取货物名称/车号,分录标识取单据号
|
||||
*/
|
||||
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()));
|
||||
/** 单张结算单分录解析上下文:结算单/项目档案与合同计费方案税率、费用项档案分类映射 */
|
||||
private record EntryResolveContext(FormalSettlement settlement, ProjectApply project,
|
||||
Map<String, BigDecimal> taxRateByRule, Map<String, String> feeCategoryByName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条结算合计费用项生成一条分录:物料按装卸大类/项目业务类型映射(运输方式取结算明细首行),
|
||||
* 数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接
|
||||
* 单条结算明细行(一个运单)按 货物 → 费用大类 → 税率 拆分多条分录:
|
||||
* 一车多货(存在货物费用行)先按货物拆分,每个货物费用行再按费用大类/税率拆行;
|
||||
* 无货物费用行时结算明细行本身即唯一货物
|
||||
*/
|
||||
private void appendSummaryEntry(List<BfiEntryGroup> entryGroups, FormalSettlement settlement, ProjectApply project,
|
||||
FormalSettlementSummaryFee summaryFee, String fallbackTransportType,
|
||||
Map<String, String> billingUnitByRule, Map<String, String> unitCodeByName) {
|
||||
private void appendDetailEntries(List<BfiEntryGroup> entryGroups, EntryResolveContext context,
|
||||
FormalSettlementDetail detail, List<FormalSettlementDetailFee> detailFees) {
|
||||
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 feeItem = Func.toStr(summaryFee.getFeeItem()).trim();
|
||||
String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]";
|
||||
boolean loadingFee = feeType.contains("装卸") || feeItem.contains("装卸");
|
||||
String materialNumber = resolveMaterialNumber(loadingFee, project, fallbackTransportType, sourceDesc);
|
||||
String measureunitNumber = resolveMeasureunitNumber(billingUnitByRule, unitCodeByName, feeItem, feeType, sourceDesc);
|
||||
// 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料
|
||||
boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName());
|
||||
String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc);
|
||||
// 结算合计费用项行无运输量,数量默认 1
|
||||
BigDecimal quantity = BigDecimal.ONE;
|
||||
BigDecimal amount = money(summaryFee.getSettlementAmount());
|
||||
@@ -530,34 +521,43 @@ public class KingdeeApBillSyncSupport {
|
||||
// 分录唯一标识(awrp_entry_code):结算单号-费用类型-费用项(费用项为空时仅拼结算单号-费用类型)
|
||||
String entryCode = settlement.getFormalSettlementNo() + "-"
|
||||
+ (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()));
|
||||
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],计量单位[{}],数量[{}],单价[{}],含税金额[{}]",
|
||||
sourceDesc, entryCode, materialNumber, measureunitNumber, quantity, price, amount);
|
||||
log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}]",
|
||||
sourceDesc, entryCode, materialNumber, quantity, price, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 物料编码映射:装卸大类费用映射“装卸”;业务模式/项目业务类型含“国际”(国际运输、国际货物运输代理服务)
|
||||
* 映射“国际货物运输代理服务”;其余(国内运输/国内道路货物运输服务)拼接运单运输方式,
|
||||
* 如“国内道路货物运输服务-水路”
|
||||
* 物料编码映射(Nacos 配置 thirdparty.bfi.material.*):
|
||||
* 装卸大类(及手动添加的非运费杂费)映射装卸物料 loading;业务模式/项目业务类型含“国际”(国际运输、
|
||||
* 国际货物运输代理服务)映射国际货代服务 international;国内运输按运单运输方式查 domestic 映射
|
||||
* (公路运输/铁路运输/水路运输/航空运输),运输方式未配置映射时直接拦截报错
|
||||
*
|
||||
* @param loadingFee 该行是否属于装卸大类费用
|
||||
* @param loadingFee 该行是否属于装卸大类(或非运费类杂费)
|
||||
* @param project 结算单关联项目(业务类型/业务模式取数来源)
|
||||
* @param transportType 运单运输方式(结算合计费用项行取结算明细首行运输方式)
|
||||
* @param sourceDesc 来源行描述,用于异常提示
|
||||
* @return 金蝶物料编码(按映射规则拼出的物料名称)
|
||||
* @return 金蝶物料编码
|
||||
*/
|
||||
private String resolveMaterialNumber(boolean loadingFee, ProjectApply project, String transportType, String sourceDesc) {
|
||||
BfiProperties.Material material = bfiProperties.getMaterial();
|
||||
String materialNumber;
|
||||
if (loadingFee) {
|
||||
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_LOADING;
|
||||
materialNumber = material.getLoading();
|
||||
} else if (isInternationalBusiness(project)) {
|
||||
materialNumber = KingdeeBfiConstant.BFI_MATERIAL_INTERNATIONAL;
|
||||
materialNumber = material.getInternational();
|
||||
} else {
|
||||
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("金蝶物料映射:来源[{}],装卸大类[{}],业务模式[{}],项目业务类型[{}],运单运输方式[{}] -> 物料编码[{}]",
|
||||
@@ -580,102 +580,215 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计量单位编码解析:费用项优先、费用类型兜底匹配合同计费方案规则计费单位,
|
||||
* 再按计量单位档案(单位名称匹配)取计量单位编码,任一环节缺失直接拦截报错
|
||||
*
|
||||
* @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 解析失败按未配置处理,由计量单位解析处统一拦截报错)
|
||||
* 合同计费方案规则税率解析:费用类型/费用项 -> 税率(取首个配置了税率的规则,
|
||||
* 计费方案 JSON 解析失败按未配置处理,由税率分组处兜底为无税率组)
|
||||
*
|
||||
* @param contract 结算合同
|
||||
* @return 费用类型/费用项 -> 计费单位映射
|
||||
* @return 费用类型/费用项 -> 税率(百分数)映射
|
||||
*/
|
||||
private Map<String, String> billingUnitByRule(ContractManage contract) {
|
||||
Map<String, String> mapping = new HashMap<>();
|
||||
private Map<String, BigDecimal> taxRateByRule(ContractManage contract) {
|
||||
Map<String, BigDecimal> taxRateByRule = new HashMap<>();
|
||||
if (Func.isBlank(contract.getBillingPlanJson())) {
|
||||
return mapping;
|
||||
return taxRateByRule;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class);
|
||||
if (!(parsed instanceof List<?> plans)) {
|
||||
return mapping;
|
||||
}
|
||||
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)) {
|
||||
if (parsed instanceof List<?> plans) {
|
||||
for (Object planValue : plans) {
|
||||
if (!(planValue instanceof Map<?, ?> plan) || !(plan.get("rules") instanceof List<?> rules)) {
|
||||
continue;
|
||||
}
|
||||
String billingUnit = textValue(rule.get("billingUnit"));
|
||||
if (Func.isBlank(billingUnit)) {
|
||||
continue;
|
||||
}
|
||||
String feeItem = textValue(rule.get("feeItem"));
|
||||
String feeType = textValue(rule.get("feeType"));
|
||||
if (Func.isNotBlank(feeItem)) {
|
||||
mapping.putIfAbsent(feeItem, billingUnit);
|
||||
}
|
||||
if (Func.isNotBlank(feeType)) {
|
||||
mapping.putIfAbsent(feeType, billingUnit);
|
||||
for (Object ruleValue : rules) {
|
||||
if (!(ruleValue instanceof Map<?, ?> rule)) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal taxRate = taxRateValue(rule.get("taxRate"));
|
||||
if (taxRate == null) {
|
||||
continue;
|
||||
}
|
||||
String feeItem = textValue(rule.get("feeItem"));
|
||||
String feeType = textValue(rule.get("feeType"));
|
||||
if (Func.isNotBlank(feeItem)) {
|
||||
taxRateByRule.putIfAbsent(feeItem, taxRate);
|
||||
}
|
||||
if (Func.isNotBlank(feeType)) {
|
||||
taxRateByRule.putIfAbsent(feeType, taxRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// 计费方案 JSON 兼容历史数据,解析失败按未配置处理
|
||||
}
|
||||
log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置计费单位{}", contract.getContractNo(), mapping.size(), mapping);
|
||||
return mapping;
|
||||
log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置税率{}", contract.getContractNo(), taxRateByRule.size(), taxRateByRule);
|
||||
return taxRateByRule;
|
||||
}
|
||||
|
||||
/** 费用项 JSON 是否包含装卸大类费用(费用项名称含“装卸”) */
|
||||
private boolean hasLoadingFeeItem(String feeItemsJson) {
|
||||
/** 合同计费方案规则税率:百分数文本转数值(如 9 表示 9%),格式非法按未配置处理 */
|
||||
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)) {
|
||||
return false;
|
||||
return feeItems;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(feeItemsJson, Map.class);
|
||||
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) {
|
||||
// 兼容历史费用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) {
|
||||
return value == null ? null : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
/** 结算明细id -> 结算明细费用行(判断运单是否存在装卸大类费用) */
|
||||
/** 结算明细id -> 结算明细费用行(一车多货按货物拆分的费用快照行) */
|
||||
private Map<Long, List<FormalSettlementDetailFee>> detailFeesByDetailId(List<FormalSettlementDetail> settlementDetails) {
|
||||
List<Long> detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
@@ -698,26 +811,30 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计量单位档案:单位名称 -> 计量单位编码(批量同步时全批共享一次加载结果;
|
||||
* 应收单同步组件复用)
|
||||
* 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典
|
||||
* {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用;
|
||||
* 档案读取失败直接拦截
|
||||
*/
|
||||
public Map<String, String> measureUnitCodeByName(KingdeeSyncContext context) {
|
||||
Map<String, String> cache = context.measureUnitCodeByName();
|
||||
public Map<String, String> feeCategoryByName(KingdeeSyncContext context) {
|
||||
Map<String, String> cache = context.feeCategoryByName();
|
||||
if (!cache.isEmpty()) {
|
||||
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())) {
|
||||
throw new ServiceException("推送金蝶失败:计量单位档案读取失败,请稍后重试");
|
||||
throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试");
|
||||
}
|
||||
Map<String, String> loaded = new HashMap<>();
|
||||
for (MeasurementUnit measurementUnit : response.getData()) {
|
||||
if (measurementUnit == null || Func.isBlank(measurementUnit.getUnitName()) || Func.isBlank(measurementUnit.getUnitCode())) {
|
||||
for (FeeItem feeItem : response.getData()) {
|
||||
if (feeItem == null || Func.isBlank(feeItem.getName()) || Func.isBlank(feeItem.getFeeCategory())) {
|
||||
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);
|
||||
return cache;
|
||||
}
|
||||
@@ -895,12 +1012,11 @@ public class KingdeeApBillSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务应付单/付款申请分录:结算明细行 + 结算合计费用项 各一条(不聚合)
|
||||
* 财务应付单/付款申请分录:结算明细按 货物→费用大类→税率 拆分行 + 结算合计费用项(手动添加的每行一条)
|
||||
*
|
||||
* @param materialNumber 物料编码(按项目业务类型/业务模式/装卸大类映射)
|
||||
* @param measureunitNumber 计量单位编码(费用类型匹配合同计费方案计费单位,再匹配计量单位档案)
|
||||
* @param entryCode 分录唯一标识(awrp_entry_code):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项
|
||||
* @param quantity 该行分录数量(运费费用项目取运输量,其它费用项目默认 1)
|
||||
* @param materialNumber 物料编码(按装卸大类/项目业务模式/运单运输方式映射,thirdparty.bfi.material.* 配置)
|
||||
* @param entryCode 分录唯一标识(awrp_entry_code):结算明细拆分行取单据号-货物序号-大类-税率,结算合计费用项行取结算单号-费用类型-费用项
|
||||
* @param quantity 该行分录数量(运输类取运输量,装卸类/其它默认 1)
|
||||
* @param price 该行分录单价(结算金额/数量反算)
|
||||
* @param amount 该行金额(含税),对应明细应付金额 e_pricetaxtotal
|
||||
* @param amountNoTax 该行金额(不含税),对应明细金额 e_amount;结算合计费用项行无不含税金额为 null 不传
|
||||
@@ -908,7 +1024,7 @@ public class KingdeeApBillSyncSupport {
|
||||
* @param vehicleNo 车号(金蝶接口新字段 awrp_lpn):结算明细车号,结算合计费用项行为 null 不传
|
||||
* @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,
|
||||
String transportGoods, String vehicleNo, Long formalSettlementId) {
|
||||
}
|
||||
|
||||
+6
-38
@@ -144,7 +144,7 @@ public class KingdeeArBillSyncSupport {
|
||||
* 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。
|
||||
* 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时
|
||||
* 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。
|
||||
* 明细分录与应付单(A102-2)同源同口径:页面结算明细(每行一条)+ 结算合计费用项(每行一条);
|
||||
* 明细分录与应付单(A102-2)同源同口径:页面结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项(每行一条);
|
||||
* 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成
|
||||
*
|
||||
* @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接)
|
||||
@@ -225,18 +225,14 @@ public class KingdeeArBillSyncSupport {
|
||||
param.setExchangerate(settlement.getExchangeRate());
|
||||
}
|
||||
|
||||
// 明细分录:与应付单同源同口径——结算明细行 + 结算合计费用项各一条(不聚合),无吨位时数量兜底 1
|
||||
// 明细分录:与应付单同源同口径——结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条,无吨位时数量兜底 1
|
||||
List<BfiArBillSaveEntryDTO> entry = new ArrayList<>();
|
||||
for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) {
|
||||
BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO();
|
||||
|
||||
String materialNumber = entryGroup.materialNumber();
|
||||
|
||||
// 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源)
|
||||
row.setEMaterialNumber(materialNumber);
|
||||
// 【必填】计量单位.编码:费用类型匹配合同计费方案计费单位,再匹配计量单位档案编码
|
||||
row.setEMeasureunitNumber(entryGroup.measureunitNumber());
|
||||
// 【必填】数量:运费费用项目取运输量,其它费用项目默认 1
|
||||
// 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置)
|
||||
row.setEMaterialNumber(entryGroup.materialNumber());
|
||||
// 数量:运输类取运输量,装卸类/其它默认 1
|
||||
row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
|
||||
// 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传
|
||||
row.setAwrpTransport(entryGroup.transportGoods());
|
||||
@@ -256,34 +252,6 @@ public class KingdeeArBillSyncSupport {
|
||||
// 分录备注(非必传)
|
||||
// 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);
|
||||
}
|
||||
param.setEntry(entry);
|
||||
@@ -306,7 +274,7 @@ public class KingdeeArBillSyncSupport {
|
||||
|
||||
/**
|
||||
* 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径——
|
||||
* 页面结算明细每行一条 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送),
|
||||
* 页面结算明细按 货物→费用大类→税率 拆分行 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送),
|
||||
* 物料/计量单位/数量/单价/金额/分录标识取数规则一致,
|
||||
* 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税))
|
||||
*
|
||||
|
||||
+5
-28
@@ -149,7 +149,7 @@ public class KingdeeReceivedInvoiceSyncSupport {
|
||||
/**
|
||||
* 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。
|
||||
* 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位;
|
||||
* 明细分录与财务应付单 A102-2 同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/金额取自
|
||||
* 明细分录与财务应付单 A102-2 同源同口径:结算明细按 货物→费用大类→税率 拆分行+手动添加结算合计费用项每行一条,物料/数量/单价/金额取自
|
||||
* 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。
|
||||
* 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码),
|
||||
* 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开
|
||||
@@ -232,7 +232,8 @@ public class KingdeeReceivedInvoiceSyncSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分录组装与应付单 A102-2 明细同源同口径:结算明细行+结算合计费用项各一条(不聚合),物料/数量/单价/含税/不含税金额
|
||||
* 分录组装与应付单 A102-2 明细同源同口径:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条,
|
||||
* 物料/数量/单价/含税/不含税金额
|
||||
* 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键,
|
||||
* 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1;
|
||||
* 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补)
|
||||
@@ -250,32 +251,8 @@ public class KingdeeReceivedInvoiceSyncSupport {
|
||||
entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE);
|
||||
// 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考)
|
||||
entry.setPrice(entryGroup.price());
|
||||
// 【必填】物料编码.编码:按项目业务类型/业务模式/装卸大类映射(与应付单同源)
|
||||
String materialNumber = 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);
|
||||
// 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置,与应付单同源)
|
||||
entry.setMaterialNumber(entryGroup.materialNumber());
|
||||
// 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传)
|
||||
if (entryGroup.amountNoTax() != null) {
|
||||
entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax()));
|
||||
|
||||
Reference in New Issue
Block a user