# BOTP 应用场景开发介绍

> BOTP是Business Object TransformPlatform的缩写，是基于业务对象（Business Object）技术的数据转换平台。  

本文以具体场景入手，介绍如何通过开发来解决BOTP应用中遇到的业务问题。

本文的读者是业务开发、二次开发人员，需要掌握java语言，熟悉单据、操作插件开发，了解BOTP业务。





#  场景：后台自动下推

##  需求背景

源单需要在审核通过后自动生成下游单据。

这个需求，正常情况下，可以通过配置实现，无需开发代码，配置方法：
修改源单的审核操作，配置操作服务，添加自动下推服务。

某些特殊情况，无法通过配置实现自动下推，那么就得自行开发代码，调用单据转换服务，实现下推。

本章介绍如何调用下推服务，下推并保存目标单，然后获取下推生成的目标单，执行提交、审核操作；



##  案例设计

假设有源单，标识为demo_bill1；有两条数据，内码为10001、10002；

需要自动生成目标单，标识demo_bill2；

源单和目标单之间，已经建立并启用了转换规则，关联主实体设为单据体1（entryentity），并配置好了字段映射关系。

 

##  相关知识点

实现上述需求，需要用到如下类：

- **PushArg**：下推参数，允许指定好源单、目标单标识，源单需要下推的数据行，以及下推时采用的转换规则等；
- **ConvertServiceHelper**：单据转换帮助类，提供push，pushAndSave方法实现下推；
- **ConvertOperateResult**：下推服务返回的结果，包含生成的目标单数据包，或目标单内码，以及下推失败的错误信息；
- **OperateionServiceHelper**：操作服务帮助类，可以完成目标单的提交、审核等操作；

 

##   示例代码

示例代码逻辑思路：

- 包装下推参数PushArgs
- 调用下推引擎，下推并保存目标单，获取下推结果
- 判断下推结果
- 调用提交操作服务，提交目标单；获取提交结果
- 判断提交结果

 

示例代码（仅用于演示如何调用下推、保存、提交等服务，未实际跑通）：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import java.util.ArrayList;
import java.util.List;

import kd.bos.data.BusinessDataReader;
import kd.bos.dataentity.OperateOption;
import kd.bos.dataentity.entity.DynamicObject;
import kd.bos.dataentity.metadata.IDataEntityType;
import kd.bos.entity.EntityMetadataCache;
import kd.bos.entity.MainEntityType;
import kd.bos.entity.botp.runtime.ConvertOperationResult;
import kd.bos.entity.botp.runtime.PushArgs;
import kd.bos.entity.botp.runtime.SourceBillReport;
import kd.bos.entity.datamodel.IRefrencedataProvider;
import kd.bos.entity.datamodel.ListSelectedRow;
import kd.bos.entity.operate.OperateOptionConst;
import kd.bos.entity.operate.result.IOperateInfo;
import kd.bos.entity.operate.result.OperationResult;
import kd.bos.exception.KDBizException;
import kd.bos.servicehelper.botp.ConvertServiceHelper;
import kd.bos.servicehelper.operation.OperationServiceHelper;
import kd.bos.servicehelper.operation.SaveServiceHelper;

/**
 * 示例代码：自动生成目标单
 * 
 * @author rd_johnnyding
 *
 */
public class AutoPushSample {

	private final static String DEMO_BILL1 = "demo_bill1";
	private final static String DEMO_BILL2 = "demo_bill2";
	
	/**
	 * 演示自动生成并提交目标单
	 */
	@SuppressWarnings("unused")
	public void autoPushAndSubmit() {

		// 创建下推参数，把源单demo_bill1的两张单据10001, 10002，下推生成目标单demo_bill2
		PushArgs pushArgs = new PushArgs();
		pushArgs.setSourceEntityNumber(DEMO_BILL1);		// 必选，源单标识
		pushArgs.setTargetEntityNumber(DEMO_BILL2);		// 必选，目标单标识
		pushArgs.setHasRight(false);		// 可选，传入true，不检查目标单新增权
		pushArgs.setAppId("");				// 可选，传入目标单验权使用的应用编码
		pushArgs.setDefOrgId(0L);			// 可选，传入目标单主组织默认值
		pushArgs.setRuleId("");				// 可选，传入本次下推使用的转换规则id；传入空值，由系统自动寻找合适的转换规则
		pushArgs.setBuildConvReport(true);	// 是否输出详细错误报告
		
		// 设置需要下推的单据，或分录行
		List<ListSelectedRow> selectedRows = new ArrayList<>();
		ListSelectedRow srcBill1 = new ListSelectedRow(10001L);
		srcBill1.setEntryEntityKey("");			// 可选，如按分录行下推必填：填单据体标识如entryentity
		srcBill1.setEntryPrimaryKeyValue(0L);	// 可选，如按分录行下推必填：填分录行内码如10001
		selectedRows.add(srcBill1);

		ListSelectedRow srcBill2 = new ListSelectedRow(10002L);
		selectedRows.add(srcBill2);
		
		pushArgs.setSelectedRows(selectedRows);		// 必选，设置需要下推的源单及分录内码
		
		// 调用下推引擎，下推目标单
		ConvertOperationResult pushResult = ConvertServiceHelper.push(pushArgs);
		
		// 判断下推是否成功，如果失败，提炼失败消息输出
		if (!pushResult.isSuccess()) {
			String errMessage = pushResult.getMessage();	// 错误摘要
			for(SourceBillReport billReport : pushResult.getBillReports()) {
				// 演示如何提取各单错误报告
				if (!billReport.isSuccess()) {
					String billMessage = billReport.getFailMessage();
				}
			}
			throw new KDBizException("下推失败:" + errMessage);
		}
		
		// 获取生成的目标单数据包
		MainEntityType targetMainType = EntityMetadataCache.getDataEntityType(DEMO_BILL2);
		List<DynamicObject> targetBillObjs = pushResult.loadTargetDataObjects(new IRefrencedataProvider() {
			@Override
			public void fillReferenceData(Object[] objs, IDataEntityType dType) {
				BusinessDataReader.loadRefence(objs, dType);
			}
		}, 	targetMainType);
		
		// 设置保存参数，调用目标单保存服务，保存目标单（保存时自动反写源单）
		OperateOption saveOption = OperateOption.create();
		saveOption.setVariableValue(OperateOptionConst.IGNOREWARN, String.valueOf(true));	// 不执行警告级别校验器
		saveOption.setVariableValue(OperateOptionConst.IGNOREINTERACTION, String.valueOf(true));	// 不显示交互提示，自动执行到底
		saveOption.setVariableValue(OperateOptionConst.STRICTVALIDATION, String.valueOf(true));		// 全部校验通过才保存
		
		OperationResult saveResult = SaveServiceHelper.saveOperate(
				DEMO_BILL2, 
				targetBillObjs.toArray(new DynamicObject[0]),
				saveOption);
		
		// 判断保存结果
		if (!saveResult.isSuccess()) {
			String errMessage = saveResult.getMessage();	// 错误摘要
			// 演示提取保存详细错误
			for(IOperateInfo errInfo: saveResult.getAllErrorOrValidateInfo()) {
				String detailMessage = errInfo.getMessage();
			}
			throw new KDBizException("保存失败：" + errMessage);
		}
		
		// 设置提交操作，调用目标单提交服务
		OperateOption submitOption = OperateOption.create();
		submitOption.setVariableValue(OperateOptionConst.IGNOREWARN, String.valueOf(true));	// 不执行警告级别校验器
		submitOption.setVariableValue(OperateOptionConst.IGNOREINTERACTION, String.valueOf(true));	// 不显示交互提示，自动执行到底
		submitOption.setVariableValue(OperateOptionConst.STRICTVALIDATION, String.valueOf(true));		// 全部校验通过才保存
		
		OperationResult submitResult = OperationServiceHelper.executeOperate(
				"submit", 
				DEMO_BILL2, 
				saveResult.getSuccessPkIds().toArray(), 
				submitOption);
		
		// 判断提交结果
		if (!submitResult.isSuccess()) {
			String errMessage = submitResult.getMessage();	// 错误摘要
			// 演示提取提交详细错误
			for(IOperateInfo errInfo: submitResult.getAllErrorOrValidateInfo()) {
				String detailMessage = errInfo.getMessage();
			}
			throw new KDBizException("提交失败：" + errMessage);
		}
		
	}
}

```





#    场景：引入单据，关联反写源单

##   需求背景

引入单据，或者使用WebApi导入数据时，需要同步建立和源单的关联关系，以支持联查、反写。

通常，在引入文件（或webapi数据包）中填好关联子实体行数据，填写好源单内码、分录内码，引入保存时，即会自动建立起与源单的关联关系。

但这种方式，需要在整理引入文件时，就能够确定源单内码、分录行内码，内码通常是不可见的，很难做到手工录入。

为了规避难以填写内码的难题，现场可以通过填写一些有意义的字段，如源单单据编号、源单行序号等，在引入过程中，开发引入插件根据这些字段值自动建立起与源单之间的关联关系。

本章介绍如何开发引入插件，根据源单单据编号，单据体源单行序号字段，自动建立起与源单的关联关系。

 



##   案例设计

假设下游的单据头，有如下几个字段，需要在引入文件中填写好，以便插件能够据此找到源单行内码：

- **srcbilltype**：单据头字段，基础资料字段类型，记录源单类型，如demo_bill1；
- **srcbillno**：单据头字段，文本字段，记录源单单据编号，如”JD-001”；
- **srcentrykey**：单据头字段，文本字段，记录源单单据体名称，如”entryentity”；
- **srcrowseq**：单据体字段，整数类型，记录源单行序号，如1,2,3…

 



##   相关知识点

- **关联子实体**：系统会在目标单的单据体下级，创建一个关联子表，记录源单类型、源单内码、分录行内码，保存时，会基于此建立起与源单的关联关系并反写。关联子实体，通常以单据体标识加上”_lk”后缀命名，又可称为lk子实体、lk表等。如果是单据头的下级关联子实体，标识固定为”billhead_lk”。



- **实体表格编码**：也称为tableId，为了明确的追查到源单行，关联子实体中，需要记录源单类型、源单单据体名称。这两个值是文本类型，直接记录会占用比较多的空间。因此，平台特别为每一种单据、单据体，都分配一个唯一的长整型数字，作为表格编码，即tableId。并改造关联子实体，只记录一个长整数的tableid，不再记录源单类型、单据体名称，节省不少存储空间。联查时，会自动根据表格编码，反查到明确的单据类型及单据体。



- **TableDefine对象**：平台封装的公共对象，包含了主实体编码、单据体名称和表格TableId之间的对应关系。可以通过公共服务接口(EntityMetadataCache.loadTableDefine方法)，传入单据主实体编码+单据体名称获取，也可以传入tableId获取；通过这个对象，可以根据单据和单据体追查tableId，也可以根据tableId反查单据和单据体。

   

 

##   示例代码

案例实现思路：

- 开发单据的表单插件，派生自AbstractBillPlugIn；
- 重写afterImportData方法，此方法在引入字段值填写完毕，保存之前触发
- 读取目标上引入的源单类型、源单编号字段值
- 根据源单编号，加载源单数据包，只需要加载源单内码、分录行内码、分录行序号；
- 对源单分录行进行逐行遍历，记录源单行号和源单内码、源单行内码之间的对应关系；
- 对目标单分录行进行逐行遍历，根据引入的源单行号，找到对应的源单内码、分录行内码；
- 把对应的源单内码、分录内码，填写在关联子实体中；
- 至此，下游单据中，已经记录好了关联的源单信息；保存时，会根据这些信息自动建立关联关系，支持联查，支持反写；

 



示例代码：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import kd.bos.bill.AbstractBillPlugIn;
import kd.bos.dataentity.entity.DynamicObject;
import kd.bos.dataentity.entity.DynamicObjectCollection;
import kd.bos.dataentity.utils.StringUtils;
import kd.bos.entity.BillEntityType;
import kd.bos.entity.EntityMetadataCache;
import kd.bos.entity.botp.runtime.TableDefine;
import kd.bos.entity.datamodel.events.ImportDataEventArgs;
import kd.bos.orm.query.QCP;
import kd.bos.orm.query.QFilter;
import kd.bos.servicehelper.BusinessDataServiceHelper;

/**
 * 示例代码：开发下游单单据插件，在引入时建立和源单关联信息，以支持联查、反写
 * 
 * @author rd_johnnyding
 */
public class AutoLinkSample extends AbstractBillPlugIn {

	// 下游单据头字段：源单类型字段，记录源单的标识，如"demo_bill1"
	private String KEY_SOURCEBILLTYPE = "srcbilltype";
	// 下游单据头字段：源单单据编号字段，记录源单单据编号，如 "JD-0001"
	private String KEY_SOURCEBILLNO = "srcbillno";
	// 下游单据头字段：源单关联主单据体标识字段，记录源单单据体标识，如 "entryentity"
	private String KEY_SOURCEENTRYKEY = "srcentrykey";
	// 下游单据体字段：源单关联主单据行号字段，记录源单行号，如 1,2,3...
	private String KEY_SOURCEROWSEQ = "srcrowseq";
	
	/**
	 * 引入字段值填写完毕，保存前，触发此事件
	 */
	@Override
	public void afterImportData(ImportDataEventArgs e) {
		this.linkSourceRow(this.getModel().getDataEntityType().getName(), "entryentity");
	}
	
	/**
	 * 根据引入的源单编号和物料，自动匹配源单行，在lk子实体记录源单行内码
	 */
	private void linkSourceRow(String targetEntityNumber, String targetEntryKey) {
		
		// 读取单据头源单类型、源单编号字段值
		Object srcEntityFldValue = this.getModel().getValue(KEY_SOURCEBILLTYPE);
		String srcEntityNumber = "";
		if (srcEntityFldValue instanceof DynamicObject) {
			srcEntityNumber = (String)((DynamicObject)srcEntityFldValue).getPkValue();
		}
		else {
			srcEntityNumber = (String)srcEntityFldValue;
		}
		String srcBillNo = (String) this.getModel().getValue(KEY_SOURCEBILLNO);
		String srcEntryKey = (String) this.getModel().getValue(KEY_SOURCEENTRYKEY);
		
		if (StringUtils.isBlank(srcEntityNumber) || StringUtils.isBlank(srcBillNo) || StringUtils.isBlank(srcEntryKey)) {
			// 没有填写源单类型、源单编号字段值，找不到源单，无法建立与源单的关联
			return;
		}

		// 读取源单数据包
		DynamicObject[] srcObjs = this.loadSourceBill(srcEntityNumber, srcEntryKey, srcBillNo);
		if (srcObjs.length == 0) {
			return;
		}
		
		// 循环分析源单行，提取源单行号、源单内码、分录行内码对应关系，放在字典中备用: key = 源单行号； value = [源单内码、分录行内码]
		Map<Integer, Object[]> srcRowIds = new HashMap<>();
		for(DynamicObject srcObj : srcObjs) {
			DynamicObjectCollection srcRows = srcObj.getDynamicObjectCollection(srcEntryKey);
			for (DynamicObject srcRow : srcRows) {
				Integer srcRowSeq = srcRow.getInt("seq");
				Object[] srcRowId = new Object[] {srcObj.getPkValue(), srcRow.getPkValue()};
				srcRowIds.put(srcRowSeq, srcRowId);
			}
		}
		
		// 获取源单单据体的表格编码：传入源单标识、源单单据体，记录关联关系时，需要用此编码，标识源单及单据体
		TableDefine tableDefine = EntityMetadataCache.loadTableDefine(srcEntityNumber, srcEntryKey);
		Long srcTableId = tableDefine.getTableId();
		
		// 拼接处关联子实体标识：如果是单据头下的lk子表，固定使用billhead_lk；如果是单据体下的lk子表，用单据体标识+lk
		String lkEntryKey = StringUtils.equals(targetEntityNumber, targetEntryKey) ? "billhead_lk" : targetEntryKey + "_lk"; 
		
		// 循环分析下游单的单据体行，逐一建立起与源单行的关联关系
		DynamicObject targetObj = this.getModel().getDataEntity(true);
		DynamicObjectCollection targetRows = targetObj.getDynamicObjectCollection(targetEntryKey);
		for(DynamicObject targetRow : targetRows) { 
			// 获取下级_lk子实体行
			DynamicObjectCollection linkRows = targetRow.getDynamicObjectCollection(lkEntryKey);
			if (!linkRows.isEmpty()) {
				continue;
			}
			linkRows.clear();
			
			// 寻找匹配的源单行：提取本行的匹配字段值，和源单行进行比对
			Integer srcRowSeq = targetRow.getInt(KEY_SOURCEROWSEQ);
			if (srcRowIds.containsKey(srcRowSeq)) {
				// 找到了匹配的行，创建一条_lk子实体上数据，记录源单内码
				DynamicObject linkRow = new DynamicObject(linkRows.getDynamicObjectType());
				linkRows.add(linkRow);
				
				// 在lk行中，记录源单分录表格编码、源单内码、源单分录内码
				Object[] srcRowId = srcRowIds.get(srcRowSeq);
				linkRow.set(lkEntryKey + "_stableid", srcTableId);		// 源单分录表格编码：以此标识源单类型及单据体
				linkRow.set(lkEntryKey + "_sbillid", srcRowId[0]);		// 源单内码
				linkRow.set(lkEntryKey + "_sid", srcRowId[1]);		// 源单分录行内码	
			}
		}
		
		// 至此：目标单分录行上，已经自动填写源单分录行内码；保存时，会自动创建关联关系及反写
	}
	
	/**
	 * 从目标单中提取源单编号，读取出源单数据包
	 * 
	 * @param srcEntityNumber 源单主实体编码
	 * @param srcEntryKey 源单关联主单据体标识
	 * @param srcBillNo 源单单据编号
	 * 
	 * @return 源单数据包，仅包含源单内码、源单编号、源单行内码、行号
	 */
	private DynamicObject[] loadSourceBill(String srcEntityNumber, String srcEntryKey, String srcBillNo) {
		
		BillEntityType sourceMainType = (BillEntityType)EntityMetadataCache.getDataEntityType(srcEntityNumber);
		
		// 读取源单：仅读取需要用到的字段：源单内码、源单单据编号、源单行内码、源单行号
		Set<String> selectFields = new HashSet<>();
		selectFields.add("id");
		selectFields.add(sourceMainType.getBillNo());
		selectFields.add(srcEntryKey + ".id");		
		selectFields.add(srcEntryKey + ".seq");
		
		QFilter filter = new QFilter(sourceMainType.getBillNo(), QCP.equals, srcBillNo);
		DynamicObject[] sourceBillObjs = BusinessDataServiceHelper.load(
				srcEntityNumber, 
				StringUtils.join(selectFields.toArray(), ","), 
				new QFilter[] {filter});
		return sourceBillObjs;
	}
}

```





#    场景：手工修复与源单关联关系

##   需求背景

下游单据是手工录入、或者引入的，与源单没有关联关系，不支持联查、反写。

对于已经保存的下游单据，现在用户需要手工修复与源单的关联关系，以支持联查、反写。

本章介绍如何开发表单插件，根据下游单据上填写好的的源单编号、源单行序号，自动建立起与源单的关联关系。

 

 

##   案例设计

假设下游的单据头，有如下几个字段，在手工录单（或引入）时，由调用者填写好源单信息：

- **srcbilltype**：单据头字段，基础资料字段类型，记录源单类型，如demo_bill1；
- **srcbillno**：单据头字段，文本字段，记录源单单据编号，如”JD-001”；
- **srcentrykey**：单据头字段，文本字段，记录源单单据体名称，如”entryentity”；
- **srcrowseq**：单据体字段，整数类型，记录源单行序号，如1,2,3…

 



##   相关知识点

- **关联子实体**：系统会在目标单的单据体下级，创建一个关联子表，记录源单类型、源单内码、分录行内码，保存时，会基于此建立起与源单的关联关系并反写。关联子实体，通常以单据体标识加上”_lk”后缀命名，又可称为lk子实体、lk表等。如果是单据头的下级关联子实体，标识固定为”billhead_lk”。



- **实体表格编码**：也称为tableId，为了明确的追查到源单行，关联子实体中，需要记录源单类型、源单单据体名称。这两个值是文本类型，直接记录会占用比较多的空间。因此，平台特别为每一种单据、单据体，都分配一个唯一的长整型数字，作为表格编码，即tableId。并改造关联子实体，只记录一个长整数的tableid，不再记录源单类型、单据体名称，节省不少存储空间。联查时，会自动根据表格编码，反查到明确的单据类型及单据体。



- **TableDefine对象**：平台封装的公共对象，包含了主实体编码、单据体名称和表格TableId之间的对应关系。可以通过公共服务接口(EntityMetadataCache.loadTableDefine方法)，传入单据主实体编码+单据体名称获取，也可以传入tableId获取；通过这个对象，可以根据单据和单据体追查tableId，也可以根据tableId反查单据和单据体。



 

##   示例代码

案例实现思路：：

- 开发单据的表单插件，派生自AbstractBillPlugIn；
- 重写itemClick方法，响应用户点击菜单事件，调用一个帮助类，修复关联关系；
- 封装一个独立的帮助类，根据传入的参数，修复下游单据与源单的关联关系，修复逻辑：
  - 读取目标上引入的源单类型、源单编号字段值
  - 根据源单编号，加载源单数据包，只需要加载源单内码、分录行内码、分录行序号；
  - 对源单分录行进行逐行遍历，记录源单行号和源单内码、源单行内码之间的对应关系；
  - 对目标单分录行进行逐行遍历，根据引入的源单行号，找到对应的源单内码、分录行内码；
  - 把对应的源单内码、分录内码，填写在关联子实体中；
  - 调用反写引擎，提交已经修复好的目标单数据包，执行关联关系创建与反写源单；

 

示例代码：

### 界面插件 – 响应菜单点击

如下示例代码，是一个界面的插件，响应菜单点击事件，调用修复关联关系的帮助类，传入修复参数：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import kd.bos.bill.AbstractBillPlugIn;
import kd.bos.dataentity.utils.StringUtils;
import kd.bos.form.control.events.ItemClickEvent;

public class AutoFixLinkFormPlugin extends AbstractBillPlugIn {

	@Override
	public void itemClick(ItemClickEvent evt) {
		super.itemClick(evt);
		if (StringUtils.equals(evt.getItemKey(), "btnfixlink")) {
			AutoFixLinkSample fixLink = new AutoFixLinkSample();
			fixLink.fixRowLink(
					this.getView().getModel().getDataEntityType().getName(),
					"entryentity", 
					"srcbilltype", 
					"srcbillno",
					"srcentrykey",
					"srcrowseq",
					this.getModel().getDataEntity().getPkValue());
		}
	}
	
}

```



### 帮助类 – 修复关联关系

如下示例代码，是一个专用类，根据传入的参数，修改下游单据的关联关系。

前提要求：下游单据上，必须要有源单类型、源单编号、源单单据体、源单单据体行号等四个字段，本示例代码需要根据这四个字段的值，定位源单，填写关联子实体行：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import kd.bos.dataentity.entity.DynamicObject;
import kd.bos.dataentity.entity.DynamicObjectCollection;
import kd.bos.dataentity.utils.StringUtils;
import kd.bos.entity.BillEntityType;
import kd.bos.entity.EntityMetadataCache;
import kd.bos.entity.botp.runtime.TableDefine;
import kd.bos.orm.query.QCP;
import kd.bos.orm.query.QFilter;
import kd.bos.servicehelper.BusinessDataServiceHelper;
import kd.bos.servicehelper.DispatchServiceHelper;

/**
 * 示例：提供方法，自动创建(修复)与源单的关联关系，支持联查反写
 * 
 * @author rd_johnnyding
 */
public class AutoFixLinkSample {

	/**
	 * 手动修复下游单据与源单的关联关系
	 * 
	 * @param targetEntityNumber 下游单据标识，如 demo_bill1
	 * @param targetEntryKey	下游单据的关联主单据体：下游单据可能有多个单据体，本函数只能修复其中一个单据体的关联关系
	 * @param srcBillTypeFld 	下游单据头上，记录源单类型的字段名：据此确定每张单据的源单类型；实际应用中可以改为直接传入源单类型
	 * @param srcBillNoFld		下游单据头上，记录源单单据编号的字段名：据此确定每张单据的源单
	 * @param srcEntryFld		下游单据头上，记录源单单据体标识的字段名：据此确定源单关联主单据体；实际应用中可以改为直接传入源单单据体名
	 * @param srcRowSeqFld		下游单据体上，记录源单单据体行号的字段名：据此定位源单行
	 * @param targetBillId		下游单据内码集合
	 * 
	 * @remark
	 * 1. 为了实现本函数的演示效果，需要在目标单上，建立有源单类型、源单编号、源单单据体名、源单行号等四个字段，以定位源单行；
	 *    实际应用中，可能源单类型、源单单据体名是固定的，不需要增加字段记录
	 * 2. 为了避免演示代码过于复杂，本函数只处理单张目标单；
	 *    实际应用中需要调整为支持批量处理
	 */
	public void fixRowLink(String targetEntityNumber, 
			String targetEntryKey,
			String srcBillTypeFld, String srcBillNoFld, 
			String srcEntryFld, String srcRowSeqFld, 
			Object targetBillId) {
		
		// TODO 参数检查：检查各参数是否传入了合法的值，本演示代码，各参数必填，不允许空

		// 读取需修复的目标单
		DynamicObject targetBillObj = BusinessDataServiceHelper.loadSingle(targetBillId, targetEntityNumber);

		// 获取目标单单据体的实体表格定义：主实体 + 单据体 -> 对应一个唯一的实体表格定义
		TableDefine targetTableDefine = EntityMetadataCache.loadTableDefine(targetEntityNumber, targetEntryKey);
		
		// 获取源单单据体的表格定义：记录关联关系时，需要用到此对象中的tableId值，用一个长整数值唯一标识源单及单据体
		TableDefine srcTableDefine = this.loadSrcTableDefine(targetBillObj, srcBillTypeFld, srcEntryFld);

		String srcBillNo = targetBillObj.getString(srcBillNoFld);
		
		// 根据源单编号，读取源单数据
		DynamicObject[] sourceBillObjs = this.loadSourceBill(srcTableDefine, srcBillNo);
		
		this.createLinkEntity(targetTableDefine, targetBillObj, srcTableDefine, sourceBillObjs, srcRowSeqFld);
		
		// 调用目标单的保存操作，保存维护好的关联子实体行数据，并自动调用反写引擎，创建关联关系及反写：
		SaveServiceHelper.saveOperate(
				targetEntityNumber, 					// 目标单主实体编码
				new DynamicObject[] {targetBillObj}, 	// 目标单数据包
				OperateOption.create());				// 操作参数，可通过option传入各种自定义参数
	}

	/**
	 * 从目标单单据头字段中，获取源单类型、源单单据体字段值，据此访问数据库，获取源单单据体表格定义
	 * 
	 * @param targetBillObj 目标单数据包
	 * @param srcBillTypeFld 目标单上，记录源单单据类型的字段名
	 * @param srcEntryFld 目标单上，记录源单单据体标识的字段名
	 * 
	 * @return 关联的源单及单据体表格定义
	 */
	private TableDefine loadSrcTableDefine( DynamicObject targetBillObj, String srcBillTypeFld, String srcEntryFld) {
		// 提取源单类型、源单单据编号字段值
		Object srcBillTypeFldValue = targetBillObj.get(srcBillTypeFld);
		String srcEntityNumber = "";
		if (srcBillTypeFldValue instanceof DynamicObject) {
			srcEntityNumber = (String)((DynamicObject)srcBillTypeFldValue).getPkValue();
		}
		else {
			srcEntityNumber = (String)srcBillTypeFldValue;
		}
		String srcEntryKey = targetBillObj.getString(srcEntryFld);
		if (StringUtils.isBlank(srcEntityNumber) 
				|| StringUtils.isBlank(srcEntryKey)) {
			return null;
		}

		// 获取源单单据体的表格编码：记录关联关系时，需要用此编码，标识源单及单据体
		TableDefine srcTableDefine = EntityMetadataCache.loadTableDefine(srcEntityNumber, srcEntryKey);
		
		return srcTableDefine;
	}
	
	/**
	 * 从目标单中提取源单编号，读取出源单数据包
	 *
	 * @param srcTableDefine 源单单据体实体表格定义：对象中包含了单据主实体标识、单据体标识、单据体tableId等内容
	 * @param srcBillNo 源单单据编号
	 * 
	 * @return 源单数据包，仅包含源单内码、源单编号、源单行内码、行号
	 */
	private DynamicObject[] loadSourceBill(TableDefine srcTableDefine, String srcBillNo) {
		
		BillEntityType sourceMainType = (BillEntityType)EntityMetadataCache.getDataEntityType(srcTableDefine.getEntityNumber());
		
		// 读取源单：仅读取需要用到的字段：源单内码、源单单据编号、源单行内码、源单行号
		Set<String> selectFields = new HashSet<>();
		selectFields.add("id");
		selectFields.add(sourceMainType.getBillNo());
		selectFields.add(srcTableDefine.getEntityKey() + ".id");		
		selectFields.add(srcTableDefine.getEntityKey() + ".seq");
		
		QFilter filter = new QFilter(sourceMainType.getBillNo(), QCP.equals, srcBillNo);
		DynamicObject[] sourceBillObjs = BusinessDataServiceHelper.load(
				srcTableDefine.getEntityNumber(), 
				StringUtils.join(selectFields.toArray(), ","), 
				new QFilter[] {filter});
		return sourceBillObjs;
	}
	
	/**
	 * 修改目标单，为每行单据体，添加关联子实体数据行，记录关联的源单行内码
	 * 
	 * @param targetTableDefine 目标单单据体表格定义：包含了目标单标识、单据体标识
	 * @param targetBillObj 目标单
	 * @param srcTableDefine 源单单据体表格定义：包含了源单标识、单据体标识、tableid
	 * @param sourceBillObjs 源单
	 * @param srcRowSeqFld 目标单单据体上，记录源单行号的字段名：需要基于此字段值定位源单行
	 * 
	 * @remark
	 * 根据目标单分录行上，记录的源单行号字段值，匹配找到源单行，把源单行内码，记录在目标单分录行上
	 */
	private void createLinkEntity(
			TableDefine targetTableDefine, DynamicObject targetBillObj,
			TableDefine srcTableDefine, DynamicObject[] sourceBillObjs,
			String srcRowSeqFld) {
		
		// 循环分析源单行，提取源单行号、源单内码、分录行内码的关系，放在字典中备用: key = 源单行号； value = [源单内码、分录行内码]
		Map<Integer, Object[]> srcRowIds = new HashMap<>();
		for(DynamicObject srcObj : sourceBillObjs) {
			DynamicObjectCollection srcRows = srcObj.getDynamicObjectCollection(srcTableDefine.getEntityKey());
			for (DynamicObject srcRow : srcRows) {
				Integer srcRowSeq = srcRow.getInt("seq");
				Object[] srcRowId = new Object[] {srcObj.getPkValue(), srcRow.getPkValue()};
				srcRowIds.put(srcRowSeq, srcRowId);
			}
		}
		
		Long srcTableId = srcTableDefine.getTableId();
		
		// 拼接处关联子实体标识：如果是单据头下的lk子表，固定使用billhead_lk；如果是单据体下的lk子表，用单据体标识+lk
		String lkEntryKey = targetTableDefine.getEntityKey() + "_lk"; 
		
		// 循环分析下游单的单据体行，逐一建立起与源单行的关联关系
		DynamicObjectCollection targetRows = targetBillObj.getDynamicObjectCollection(targetTableDefine.getEntityKey());
		for(DynamicObject targetRow : targetRows) { 
			// 获取下级_lk子实体行
			DynamicObjectCollection linkRows = targetRow.getDynamicObjectCollection(lkEntryKey);
			if (!linkRows.isEmpty()) {
				// 已经有关联源单，无需再重建
				continue;
			}
			
			// 寻找匹配的源单行：提取本行的匹配字段值，和源单行进行比对
			Integer srcRowSeq = targetRow.getInt(srcRowSeqFld);
			if (srcRowIds.containsKey(srcRowSeq)) {
				// 找到了匹配的行，创建一条_lk子实体上数据，记录源单内码
				DynamicObject linkRow = new DynamicObject(linkRows.getDynamicObjectType());
				linkRows.add(linkRow);
				
				// 在lk行中，记录源单分录表格编码、源单内码、源单分录内码
				Object[] srcRowId = srcRowIds.get(srcRowSeq);
				linkRow.set(lkEntryKey + "_stableid", srcTableId);		// 源单分录表格编码：以此标识源单类型及单据体
				linkRow.set(lkEntryKey + "_sbillid", srcRowId[0]);		// 源单内码
				linkRow.set(lkEntryKey + "_sid", srcRowId[1]);			// 源单分录行内码	
			}
		}
	}
}

```

#    场景：搜索源单

##   需求背景

业务代码在对下游单据进行处理时，常常需要找到源头的核心单据（例如订单），根据核心单据的数据状态等信息，决定业务逻辑。

核心单据和下游单据之间，经过了多次的单据转换（如A -> B -> C -> D ->…）。很不幸，在当前需要处理的下游单据上，并没有冗余字段记录源头核心单据的信息，无法通过本单上的字段值找到源单。

每次执行单据转换时，平台都会在后台自动记录单据之间的关联关系，并提供了相应的服务接口，查询各单之间的关系。上述需求，可以通过平台提供的服务接口实现。

说明：正因为平台会在后台自动记录单据之间的关联关系，在设计下游单据时，其实不需要考虑冗余太多字段，一层层携带、记录源头核心单据信息。

本章介绍如何使用平台提供的服务接口，搜索源单。



 

##   案例设计

假设源头核心单据是demo_botp1，当前处理的下游单据是demo_botp3： 

- demo_botp1 下推生成 demo_botp2；
- demo_botp2 接着下推生成 demo_botp3；
- 当前正在审核demo_botp3，需要找到源头的demo_botp1；

 

##   相关知识点

- **BFTrackerServiceHelper**：平台封装的单据关联关系追踪服务，可以搜索源单、搜索目标单、搜索分录行的关联追溯树；

 

##   示例代码

案例实现思路：

- 开发单据demo_botp3的审核操作插件，派生自AbstractOperationServicePlugIn；
- 重写beforeExecuteOperationTransaction方法，此方法在操作校验通过，开启事务保护前触发，可以在此事件中，检查、整理数据；
- 调用BFTrackerServiceHelper服务方法，传入本单主实体编码和单据内码，搜索本单的全部源单；从中获取需要查找的源单demo_botp1；
- 根据取到的源头demo_botp1单据内码，进行后续处理。

 

**特别说明：**

本示例使用的BFTrackerServiceHelper.findSourceBills只是整体搜索出所有源单，未区分各单直接的对应关系，这在批量处理时会有限制。如果需要准确区分各单对应关系，需要使用BFTrackerServiceHelper.loadLinkUpNodes获取行级追溯树，然后从其中寻找源单，详细过程，请参阅另外一个场景、示例。

 

示例代码：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import kd.bos.entity.ExtendedDataEntity;
import kd.bos.entity.plugin.AbstractOperationServicePlugIn;
import kd.bos.entity.plugin.args.BeforeOperationArgs;
import kd.bos.servicehelper.botp.BFTrackerServiceHelper;

/**
 * 示例：在单据审核时，搜索源头单据
 * 
 * @author rd_johnnyding
 *
 */
public class FindSourceBillOpPlugin extends AbstractOperationServicePlugIn {

	/**
	 * 开启事务保护前，触发本事件：对数据进行检查、整理
	 */
	@Override
	public void beforeExecuteOperationTransaction(BeforeOperationArgs e) {
		
		if (e.getValidExtDataEntities().isEmpty()) {
			return;
		}
		
		// 获取当前单据（下游单据）的主实体编码、单据内码
		String targetEntityNumber = this.billEntityType.getName();
		Set<Object> billIds = new HashSet<>();
		for(ExtendedDataEntity dataEntity : e.getValidExtDataEntities()) {
			billIds.add(dataEntity.getBillPkId());
		}
		
		// 调用平台的服务，获取所有源单及其内码
		Map<String, HashSet<Long>> sourceBillIds = BFTrackerServiceHelper.findSourceBills(targetEntityNumber, billIds.toArray(new Long[0]));
		
		// 从所有源单中寻找需要的demo_botpbill1
		HashSet<Long> botpbill1_Ids = new HashSet<>();
		String botpbill1_EntityNumber = "demo_botp1";
		if (sourceBillIds.containsKey(botpbill1_EntityNumber)) {
			botpbill1_Ids = sourceBillIds.get(botpbill1_EntityNumber);
		}
		
		if (!botpbill1_Ids.isEmpty()) {
			// TODO 已经获取到了源头的demo_botpbill1单据内码，可以进行后续处理
		}
	}
}

```







#    场景：搜索下游单

##   需求背景

业务代码在对上游单据进行处理时，需要联查到某种特定的下游单据，一起做相关处理（如同步删除等）。

平台会在后台自动记录单据间的关联关系，并提供了服务接口，查询单据间关联关系。

本章介绍如何如何使用平台提供的服务接口，搜索特定的下游单据。

 

##   案例设计

假设源头核心单据是demo_botp1，需要搜索下游单据demo_botp2： 

- demo_botp1 审核时，自动下推生成了 demo_botp2；
- 现在要在删除demo_botp1时，找到下游单据demo_botp2并自动删除；

 

**特别说明：**

正常情况下，源单反审核、删除，都会增加是否下推检查，如果已经下推了，不允许反审核和删除。本示例为了演示效果，去掉了是否下推检查。

 

##   相关知识点

- **IsPush函数**：操作校验器上，可以使用IsPush函数，检查有没有下级单据，如果有，应该禁止反审核、删除。

 

##   示例代码

案例实现思路：

- 开发单据demo_botp1的删除操作插件，派生自AbstractOperationServicePlugIn；
- 重写beginExecuteOperationTransaction方法，此方法在操作校验通过后，已经开启了事务保护时触发，在此事件中修改数据库数据，受事务保护。本示例是需要同步删除下游单据，如果失败，需要自动回滚，因此，需要把删除下游单据的代码，放在本事件中，受事务保护；
- 调用BFTrackerServiceHelper服务方法，传入本单主实体编码和单据内码，搜索本单的全部下游单据；从中获取需要同步删除的下游单据demo_botp2
-  调用删除服务，删除下游单据demo_botp2；

 

**特别说明：**

在beginExecuteOperationTransaction事件中，不能通过调用微服务方法删除其他单据，即事务不允许跨越微服务，只能使用本地调用的方式实现。

 

示例代码：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import kd.bos.dataentity.OperateOption;
import kd.bos.dataentity.entity.DynamicObject;
import kd.bos.entity.operate.result.OperationResult;
import kd.bos.entity.plugin.AbstractOperationServicePlugIn;
import kd.bos.entity.plugin.args.BeginOperationTransactionArgs;
import kd.bos.exception.KDBizException;
import kd.bos.service.operation.OperationServiceImpl;
import kd.bos.servicehelper.botp.BFTrackerServiceHelper;

/**
 * 示例：演示自动找到并删除下游单据
 * 
 * @author rd_johnnyding
 *
 */
public class FindTargetBillOpPlugin extends AbstractOperationServicePlugIn {

	/**
	 * 数据检查通过，启动事务保护，准备删除数据时，触发此事件
	 */
	@Override
	public void beginOperationTransaction(BeginOperationTransactionArgs e) {
		
		if (e.getDataEntities().length == 0) {
			return;
		}
		
		// 获取当前单据（上游单据）的主实体编码、单据内码
		String sourceEntityNumber = this.billEntityType.getName();
		Set<Object> billIds = new HashSet<>();
		for(DynamicObject dataEntity : e.getDataEntities()) {
			billIds.add(dataEntity.getPkValue());
		}
		
		// 调用平台的服务，获取所有目标单及内码
		Map<String, HashSet<Long>> targetBillIds = BFTrackerServiceHelper.findTargetBills(sourceEntityNumber, billIds.toArray(new Long[0]));
		if (targetBillIds.size() > 1) {
			// 有多种下游单据：不允许删除
			throw new KDBizException("单据已经下推，不允许删除！");
		}
		
		String botpbill2_EntityNumber = "demo_botp2";
		if (targetBillIds.size() == 1 && !targetBillIds.containsKey(botpbill2_EntityNumber)) {
			// 有其他下游单据：不允许删除
			throw new KDBizException("单据已经下推，不允许删除！");
		}
		
		// 获取需要同步删除的demo_botpbill2单据内码
		HashSet<Long> botpbill2_Ids = new HashSet<>();
		if (targetBillIds.containsKey(botpbill2_EntityNumber)) {
			botpbill2_Ids = targetBillIds.get(botpbill2_EntityNumber);
		}
		
		if (!botpbill2_Ids.isEmpty()) {
			// 删除下游单据（本方法是在事务中，如果删除失败，可以回滚）
			// 要在事务中同步删除其他单据，不能调用微服务，只能本地调用，
			OperationServiceImpl delService = new OperationServiceImpl();
			//OperationResult delResult = OperationServiceHelper.executeOperate(// 微服务调用 
			OperationResult delResult = delService.localInvokeOperation(
					"delete",				// 操作名：删除 
					botpbill2_EntityNumber, // 单据主实体编码
					botpbill2_Ids.toArray(),	// 单据内码 
					OperateOption.create());	// 操作自定义参数包：可以用其携带各种自定义参数
			if (delResult.getSuccessPkIds().size() < botpbill2_Ids.size()) {
				throw new KDBizException("同步删除下游单据失败！");
			}
		}		
	}
}

```







#    场景：搜索源单行

##   需求背景

业务代码在对下游单据进行处理时，常常需要找到源头某单，并明确下游单据行与源单行之间的对应关系，根据行对应关系进行相关逻辑处理，如检查、或更新源单分录行字段值。

正常情况下，反写源单可以通过配置反写规则实现，不需要自行开发插件。

本章介绍如何使用平台提供的服务接口，搜索源单，并明确下游单据分录行和源单分录行之间的对应关系。

 

##   案例设计

假设源头核心单据是demo_botp1，当前处理的下游单据是demo_botp3： 

- demo_botp1 下推生成 demo_botp2；
- demo_botp2 接着下推生成 demo_botp3；
- 当前正在审核demo_botp3，需要找到源头的demo_botp1，并确定好行之间的对应关系；

 

##   相关知识点

- **TableDefine**：单据实体表格定义对象，记录单据、单据体分配的表格编码（即tableId），也可以使用tableId反查到是什么单据的什么单据体；
- **RowId对象**：BOTP引擎中，使用RowId对象，包装单据主表编码、单据内码、单据体表格编码、单据体内码，可以通过RowId的属性值，明确的知道其对应什么单据，那个单据体的那一行数据。
- **BFRowLinkUpNode**：行关系追溯树，以下游单据单据体行作为根节点，逐层往上记录其直接源单行。通过逐层往上的方式，找到一条分录行全链条的所有源单行，包括直接源单、跨级源单；

 

##   示例代码

案例实现思路：

- 开发单据demo_botp3的审核操作插件，派生自AbstractOperationServicePlugIn；
- 重写beforeExecuteOperationTransaction方法，此方法在操作校验通过，开启事务保护前触发，可以在此事件中，检查、整理数据；
- 调用BFTrackerServiceHelper服务方法，传入本单主实体编码和单据内码，搜索本单分录行的源单行追溯树；
- 从源单行追溯树上，寻找指定的源单行，与当前行关联起来，存储在字典中；

 

示例代码：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import kd.bos.entity.EntityMetadataCache;
import kd.bos.entity.ExtendedDataEntity;
import kd.bos.entity.botp.runtime.BFRowLinkUpNode;
import kd.bos.entity.botp.runtime.TableDefine;
import kd.bos.entity.plugin.AbstractOperationServicePlugIn;
import kd.bos.entity.plugin.args.BeforeOperationArgs;
import kd.bos.servicehelper.botp.BFTrackerServiceHelper;

/**
 * 示例：在单据审核时，搜索源头单据，并明确行之间的对应关系
 * @author rd_johnnyding
 *
 */
public class FindSourceRowOpPlugin extends AbstractOperationServicePlugIn {

	/**
	 * 开启事务保护前，触发本事件：对数据进行检查、整理
	 */
	@Override
	public void beforeExecuteOperationTransaction(BeforeOperationArgs e) {
		
		if (e.getValidExtDataEntities().isEmpty()) {
			return;
		}
		
		// 取出源单主表的表格定义、源单单据体的表格定义
		String botpbill1_EntityNumber = "demo_botp1";
		String botpbill1_EntryKey = "entryentity";
		TableDefine srcMainTable = EntityMetadataCache.loadTableDefine(botpbill1_EntityNumber, botpbill1_EntityNumber);
		TableDefine srcEntryTable = EntityMetadataCache.loadTableDefine(botpbill1_EntityNumber, botpbill1_EntryKey);
		
		// 获取当前单据（下游单据）的主实体编码、单据内码
		String targetEntityNumber = this.billEntityType.getName();
		String targetEntryKey = "entryentity";
		TableDefine targetEntryTable = EntityMetadataCache.loadTableDefine(targetEntityNumber, targetEntryKey);
		Set<Object> billIds = new HashSet<>();
		for(ExtendedDataEntity dataEntity : e.getValidExtDataEntities()) {
			billIds.add(dataEntity.getBillPkId());
		}
		
		// 调用平台的服务，获取源单行追溯树：可以从追溯树上，追查每条分录行的来历
		List<BFRowLinkUpNode> linkUpNodes = BFTrackerServiceHelper.loadLinkUpNodes(
				targetEntityNumber,			// 下游单据主实体编码 
				"", 						// 可选参数，如果需要按分录行追溯，此参数传入单据体名称，否则按整单追溯
				billIds.toArray(new Long[0]));	// 如果按分录行追溯，此参数传入分录行内码；按整单追溯，此参数传入单据内码

		// 定义一个字典，记录下游单据行，和源单行之间的对应关系：每行可能对应多行源单，多对一合并生成
		Map<Long, Set<Long>> rowId_SrcRowIds = new HashMap<Long, Set<Long>>(); 

		// 对读取出行追溯树，逐条分析，找出其对应的源单行
		for(BFRowLinkUpNode linkUpNode : linkUpNodes) {
			if (Long.compare(targetEntryTable.getTableId(), linkUpNode.getRowId().getTableId()) != 0) {
				// 其他单据体行，略过不处理
				continue;
			}
			// 获取下游单据体行内码
			Long targetRowId = linkUpNode.getRowId().getEntryId();
			// 把下游单据体行，添加进字典
			if (!rowId_SrcRowIds.containsKey(targetRowId)) {
				rowId_SrcRowIds.put(targetRowId, new HashSet<>());
			}
			
			// 传入需要查找的源单主表的tableId，搜索出源头上所有主表tableId符合的节点
			List<BFRowLinkUpNode> srcRowNodes = linkUpNode.findSourceNodes(srcMainTable.getTableId());
			// 把各源单行添加到字典中，使用目标单行内码作为key检索
			for(BFRowLinkUpNode srcRowNode : srcRowNodes) {
				if (Long.compare(srcEntryTable.getTableId(), srcRowNode.getRowId().getTableId()) != 0) {
					// 是源单其他单据体行，略过不处理
					continue;
				}
				Long srcRowId = srcRowNode.getRowId().getEntryId();
				rowId_SrcRowIds.get(targetRowId).add(srcRowId);
			}
			
		}
		
		if (!rowId_SrcRowIds.isEmpty()) {
			// TODO 至此，rowId_SrcRowIds中，已经记录了每行对应的源单行内码，可以进行后续处理
		}
	}
}

```



#    场景：搜索下游行

##   需求背景

业务代码在对上游单据进行处理时，需要找到下游单据，以及各行之间的对应关系，以确定业务流走到了什么程度，据此进行后续逻辑的处理。

本章介绍如何使用平台提供的服务接口，搜索下游单据，并找到每条源单行和目标单行之间的对应关系。

 

##   案例设计

假设源头核心单据是demo_botp1，需要寻找下游单据demo_botp2： 

- demo_botp1 可能直接下推生成 demo_botp2，也可能先下推生成其它单据，然后再由其它单据下推生成demo_botp2；
- 现在需要从demo_botp1，找到demo_botp2，并找到每条源单行和下游目标单行之间的对应关系；

 

##   相关知识点

- **BFRowLinkDownNode**：行关系追溯树，以上游单据分录行作为根节点，逐层往下发散，记录其逐层下推生成的下游单据分录行。通过逐层往下的方式，可以找到分录行全链条的全部下游单据行，包括的直接下游行、跨级下游行；

 

##   示例代码

案例实现思路：

- 开发单据demo_botp1的反审核操作插件，派生自AbstractOperationServicePlugIn；
- 重写beforeExecuteOperationTransaction方法，搜索下游单据demo_botp2分录行；
- 调用BFTrackerServiceHelper服务方法，传入本单(源单)主实体编码和单据内码，搜索本单全部分录行的下游行追溯树；
- 从下游行追溯树上，为当前单据每条分录行，寻找对应的下游目标单据分录行，把行内码对应关系，存储在字典中备用；
- 不涉及找到目标单分录行之后的业务逻辑；

 

示例代码：

```java
package kd.bos.plugin.sample.bill.billconvert.bizcase;

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import kd.bos.entity.EntityMetadataCache;
import kd.bos.entity.ExtendedDataEntity;
import kd.bos.entity.botp.runtime.BFRowLinkDownNode;
import kd.bos.entity.botp.runtime.TableDefine;
import kd.bos.entity.plugin.AbstractOperationServicePlugIn;
import kd.bos.entity.plugin.args.BeforeOperationArgs;
import kd.bos.servicehelper.botp.BFTrackerServiceHelper;

/**
 * 示例：从源头单据，寻找其下游单据，以及各行之间的对应关系
 * 
 * @author rd_johnnyding
 *
 */
public class FindTargetRowOpPlugin  extends AbstractOperationServicePlugIn {

	/**
	 * 开启事务保护前，触发本事件：对数据进行检查、整理
	 */
	@Override
	public void beforeExecuteOperationTransaction(BeforeOperationArgs e) {
		
		if (e.getValidExtDataEntities().isEmpty()) {
			return;
		}

		// 获取当前单据（上游单据）的主实体编码、单据内码
		String sourceEntityNumber = this.billEntityType.getName();
		String sourceEntryKey = "entryentity";
		TableDefine sourceEntryTable = EntityMetadataCache.loadTableDefine(sourceEntityNumber, sourceEntryKey);
		Set<Object> sourceBillIds = new HashSet<>();
		for(ExtendedDataEntity dataEntity : e.getValidExtDataEntities()) {
			sourceBillIds.add(dataEntity.getBillPkId());
		}

		// 取出下游目标单主表的表格定义、单据体的表格定义
		String targetEntityNumber = "demo_botp2";
		String targetEntryKey = "entryentity";
		TableDefine targetMainTable = EntityMetadataCache.loadTableDefine(targetEntityNumber, targetEntityNumber);
		TableDefine targetEntryTable = EntityMetadataCache.loadTableDefine(targetEntityNumber, targetEntryKey);
		
		
		// 调用平台的服务，获取下游单据追溯树：可以从追溯树上，追查每条分录行的流向
		List<BFRowLinkDownNode> linkDownNodes = BFTrackerServiceHelper.loadLinkDownNodes(
				sourceEntityNumber,			// 上游单据主实体编码 
				"", 						// 可选参数，如果需要按分录行追溯，此参数传入单据体名称，否则按整单追溯
				sourceBillIds.toArray(new Long[0]));	// 如果按分录行追溯，此参数传入分录行内码；按整单追溯，此参数传入单据内码

		// 定义一个字典，记录上游单据行，关联的全部下游目标单分录行内码：1对多，1条源单行，可能会被拆分为多条下游单分录行
		Map<Long, Set<Long>> rowId_targetRowIds = new HashMap<Long, Set<Long>>(); 

		// 遍历行追溯树，逐条源单行分析，找出其对应的下游目标单据分录行
		for(BFRowLinkDownNode linkDownNode : linkDownNodes) {
			if (Long.compare(sourceEntryTable.getTableId(), linkDownNode.getRowId().getTableId()) != 0) {
				// 当前源单的其他单据体行，略过不处理
				continue;
			}
			
			// 获取当前单据的行内码
			Long sourceRowId = linkDownNode.getRowId().getEntryId();
			// 在字典中，为当前单据的行，创建一个记录
			if (!rowId_targetRowIds.containsKey(sourceRowId)) {
				rowId_targetRowIds.put(sourceRowId, new HashSet<>());
			}
			
			// 传入需要找的下游目标单主表的tableId，进行全链条搜索
			List<BFRowLinkDownNode> targetRowNodes = linkDownNode.findTargetNodes(targetMainTable.getTableId());
			
			// 把搜索到的下游目标单分录行内码添加到字典，和源单行关联起来
			for(BFRowLinkDownNode targetRowNode : targetRowNodes) {
				if (Long.compare(targetEntryTable.getTableId(), targetRowNode.getRowId().getTableId()) != 0) {
					// 是下游目标单其他单据体分录行，略过不处理
					continue;
				}
				Long targetRowId = targetRowNode.getRowId().getEntryId();
				rowId_targetRowIds.get(sourceRowId).add(targetRowId);
			}
			
		}
		
		if (!rowId_targetRowIds.isEmpty()) {
			// TODO 至此，每条源单分录行，对应的下游目标单分录行，都已经找到，可以进行后续处理
		}
	}
}

```

