第二篇:金蝶云星空企业版表单插件开发——高级应用与实战技巧

本章导读:在本系列的第一篇中,我们学习了表单插件的基础架构、生命周期事件、数据获取方法以及KSQL数据库操作。本篇将在此基础上深入讲解表单插件的高级应用场景------F7字段的精细控制、动态表单弹窗交互、操作拦截与事务控制、以及性能优化技巧。这些技能是区分"会用插件"与"精通插件"的关键分水岭。

第1章:F7字段的精细控制

F7字段(基础资料选择字段)是金蝶云星空中最常用的字段类型之一。表单插件提供了丰富的扩展点,让开发者能够精细控制F7的过滤、赋值和联动行为。

1.1 F7字段的数据结构

在深入了解F7控制之前,必须先理解F7字段在数据包中的存储结构。一个基础资料字段在数据库中包含多个关联字段:

字段后缀 类型 存储内容
{字段Key} 大整数 基础资料内码(FID)
{字段Key}_Id 大整数 同内码(别名)
{字段Key}_Name 字符串 基础资料名称
{字段Key}_Number 字符串 基础资料编码

获取F7字段的三种方式:

csharp 复制代码
// 方式1:获取内码(推荐)
long materialId = Convert.ToInt64(this.View.Model.GetValue("F_PAEZ_MaterialId"));

// 方式2:获取完整数据包
DynamicObject material = this.View.Model.GetValue("F_PAEZ_MaterialId") as DynamicObject;
if (material != null)
{
    long id = Convert.ToInt64(material["Id"]);
    string name = material["Name"]?.ToString();
    string number = material["Number"]?.ToString();
}

// 方式3:直接获取特定属性(需要字段开启了属性绑定)
string materialName = this.View.Model.GetValue("F_PAEZ_MaterialId_Name")?.ToString();

1.2 BeforeF7Select:F7打开前的动态过滤

BeforeF7Select 在用户点击F7按钮、弹窗打开之前触发,是实现动态F7过滤的核心事件。

csharp 复制代码
using Kingdee.BOS.Core.DynamicForm.PlugIn.Args;
using Kingdee.BOS.ORM;

/// <summary>
/// F7打开前过滤
/// </summary>
public override void BeforeF7Select(BeforeF7SelectEventArgs e)
{
    base.BeforeF7Select(e);

    // 判断是哪个F7字段
    if (e.FieldKey == "F_PAEZ_MaterialId")
    {
        // 场景1:只显示已启用的物料
        e.Filter = "FUseState = 'A'";

        // 场景2:根据当前组织过滤(获取当前用户的组织)
        string orgId = this.Context.OrganizationId.ToString();
        if (!string.IsNullOrEmpty(orgId))
        {
            e.Filter = $"FUseState = 'A' AND FOrgId = '{orgId}'";
        }

        // 场景3:动态过滤------根据单据头的供应商过滤物料(物料启用了供应商关联)
        string supplierId = this.View.Model.GetValue("F_PAEZ_SupplierId")?.ToString();
        if (!string.IsNullOrEmpty(supplierId))
        {
            // 假设物料基础资料上有"默认供应商"字段
            e.Filter = $"FUseState = 'A' AND FDefaultSupplierId = '{supplierId}'";
        }

        // 场景4:排除已在当前分录中选中的物料
        int currentRow = e.RowIndex;  // 当前行索引
        if (currentRow >= 0)
        {
            HashSet<string> selectedMaterials = new HashSet<string>();
            int entryCount = this.View.Model.GetEntryRowCount("F_PAEZ_Entry");
            for (int i = 0; i < entryCount; i++)
            {
                if (i == currentRow) continue;  // 排除当前行
                string materialId = this.View.Model.GetValue("F_PAEZ_MaterialId", i)?.ToString();
                if (!string.IsNullOrEmpty(materialId))
                {
                    selectedMaterials.Add(materialId);
                }
            }
            if (selectedMaterials.Count > 0)
            {
                string excludeFilter = $"FID NOT IN ({string.Join(",", selectedMaterials)})";
                e.Filter = string.IsNullOrEmpty(e.Filter) ? excludeFilter : $"{e.Filter} AND {excludeFilter}";
            }
        }
    }

    // 供应商F7过滤:根据采购组织过滤
    if (e.FieldKey == "F_PAEZ_SupplierId")
    {
        string purchaseOrg = this.View.Model.GetValue("F_PAEZ_PurchaseOrg")?.ToString();
        if (!string.IsNullOrEmpty(purchaseOrg))
        {
            e.Filter = $"FUseState = 'A' AND FOrgId = '{purchaseOrg}'";
        }
    }
}

1.3 AfterF7Select:F7选择后的处理

AfterF7Select 在用户从F7弹窗中选择数据并确认后触发,可以实现选择后的自动填充。

csharp 复制代码
/// <summary>
/// F7选择后处理
/// </summary>
public override void AfterF7Select(AfterF7SelectEventArgs e)
{
    base.AfterF7Select(e);

    if (e.FieldKey == "F_PAEZ_MaterialId")
    {
        int rowIndex = e.RowIndex;
        if (rowIndex >= 0)
        {
            // 获取选中的物料数据包
            DynamicObject material = e.ReturnData as DynamicObject;
            if (material != null)
            {
                // 自动填充规格、型号、单价等
                this.View.Model.SetValue("F_PAEZ_Specification", material["FSpecification"], rowIndex);
                this.View.Model.SetValue("F_PAEZ_Model", material["FModel"], rowIndex);
                this.View.Model.SetValue("F_PAEZ_Unit", material["FBaseUnit"], rowIndex);
                this.View.Model.SetValue("F_PAEZ_Price", material["FPrice"], rowIndex);
                this.View.Model.SetValue("F_PAEZ_TaxRate", material["FTaxRate"], rowIndex);

                // 触发数量变更事件(重算金额)
                this.View.InvokeFieldUpdateService("F_PAEZ_Qty", rowIndex);

                // 刷新视图
                this.View.UpdateView("F_PAEZ_Entry");
            }
        }
    }

    // 供应商选择后自动填充联系人、电话、地址
    if (e.FieldKey == "F_PAEZ_SupplierId")
    {
        DynamicObject supplier = e.ReturnData as DynamicObject;
        if (supplier != null)
        {
            this.View.Model.SetValue("F_PAEZ_Contact", supplier["FContact"]);
            this.View.Model.SetValue("F_PAEZ_Phone", supplier["FPhone"]);
            this.View.Model.SetValue("F_PAEZ_Address", supplier["FAddress"]);
            this.View.UpdateView("F_PAEZ_Header");
        }
    }
}

1.4 F7字段的赋值与联动

通过插件给F7字段赋值,需要特别注意赋值方式------不能直接传字符串,需要通过构造数据包或使用特定方法。

csharp 复制代码
/// <summary>
/// 通过代码给F7字段赋值
/// </summary>
public void SetF7FieldValue()
{
    // 方式1:使用SetValue传入DynamicObject(推荐)
    DynamicObject material = GetMaterialByNumber("MAT001");
    this.View.Model.SetValue("F_PAEZ_MaterialId", material);

    // 方式2:通过内码赋值(需要字段允许通过ID赋值)
    this.View.Model.SetValue("F_PAEZ_MaterialId_Id", 123456789L);

    // 方式3:通过编码赋值(需要字段允许通过编码赋值)
    this.View.Model.SetValue("F_PAEZ_MaterialId_Number", "MAT001");
}

/// <summary>
/// 根据编码获取物料数据包
/// </summary>
private DynamicObject GetMaterialByNumber(string number)
{
    string ksql = @"
        SELECT FID, FNUMBER, FNAME, FSPECIFICATION, FPRICE 
        FROM T_BD_MATERIAL 
        WHERE FNUMBER = @Number AND FUseState = 'A'
    ";

    DataTable dt = DBServiceHelper.ExecuteDataSet(
        this.Context, 
        ksql, 
        new SqlParam[] { new SqlParam("@Number", SqlDbType.NVarChar, number) }
    ).Tables[0];

    if (dt.Rows.Count > 0)
    {
        DataRow row = dt.Rows[0];
        DynamicObject obj = new DynamicObject();
        obj["Id"] = Convert.ToInt64(row["FID"]);
        obj["Number"] = row["FNUMBER"].ToString();
        obj["Name"] = row["FNAME"].ToString();
        obj["FSpecification"] = row["FSPECIFICATION"].ToString();
        obj["FPrice"] = Convert.ToDecimal(row["FPRICE"]);
        return obj;
    }
    return null;
}

1.5 让F7字段支持模糊搜索

通过插件干预,可以让F7字段支持编码+名称的双重模糊搜索:

csharp 复制代码
public override void BeforeF7Select(BeforeF7SelectEventArgs e)
{
    base.BeforeF7Select(e);

    if (e.FieldKey == "F_PAEZ_MaterialId")
    {
        // 获取用户输入的搜索关键字
        // 注意:e.SearchText 是用户在F7搜索框中输入的内容
        string searchText = e.SearchText;
        if (!string.IsNullOrEmpty(searchText))
        {
            // 构建自定义过滤:编码或名称包含关键字
            string filter = $@"
                FUseState = 'A' AND (
                    FNUMBER LIKE '%{searchText}%' 
                    OR FNAME LIKE '%{searchText}%'
                )
            ";
            e.Filter = filter;
        }
        else
        {
            e.Filter = "FUseState = 'A'";
        }
    }
}

第2章:动态表单弹窗交互

动态表单是金蝶云星空中轻量级的弹出页面。表单插件通过 ShowForm 方法打开动态表单,并通过 ClosedCallBack 接收返回数据,实现弹窗交互。

2.1 打开动态表单(ShowForm)

csharp 复制代码
/// <summary>
/// 打开动态表单选择器
/// </summary>
public override void BarItemClick(BarItemClickEventArgs e)
{
    base.BarItemClick(e);

    if (e.BarItemKey == "F_PAEZ_SelectBOM")
    {
        // 构造打开参数
        OpenParameter parameter = new OpenParameter();
        parameter.FormId = "PAEZ_BOMSelector";      // 动态表单标识
        parameter.PageId = "F_PAEZ_BOMSelectorPage"; // 页面标识
        parameter.Put("SourceBillId", this.View.Model.GetPKValue());
        parameter.Put("SourceBillNo", this.View.Model.GetValue("FBILLNO"));

        // 方式1:无回调打开
        this.View.ShowForm(parameter);

        // 方式2:带回调打开(推荐)
        this.View.ShowForm(parameter, OnBOMSelected);
    }
}

/// <summary>
/// 回调方法:处理BOM选择结果
/// </summary>
private void OnBOMSelected(FormResult result)
{
    if (result.IsLookup && result.ReturnData != null)
    {
        DynamicObject selectedBOM = result.ReturnData as DynamicObject;
        if (selectedBOM != null)
        {
            // 获取选择的数据
            string bomId = selectedBOM["FID"]?.ToString();
            string bomName = selectedBOM["FNAME"]?.ToString();

            // 将数据写入当前单据
            this.View.Model.SetValue("F_PAEZ_BOMId", bomId);
            this.View.Model.SetValue("F_PAEZ_BOMName", bomName);

            // 根据BOM填充物料明细
            FillBOMEntry(bomId);

            this.View.ShowMessage($"已选择BOM:{bomName}");
        }
    }
    else if (result.IsCancel)
    {
        this.View.ShowMessage("用户取消了选择");
    }
}

2.2 动态表单中的参数传递

在动态表单插件中,通过 OpenParameter 接收父页面传递的参数:

csharp 复制代码
// 动态表单插件 BOMSelector
public class BOMSelectorPlugIn : AbstractBillPlugIn
{
    private string sourceBillId;

    public override void Initialize()
    {
        base.Initialize();

        // 接收父页面传递的参数
        if (this.View.OpenParameter != null)
        {
            sourceBillId = this.View.OpenParameter.GetParameter("SourceBillId") as string;
            string sourceBillNo = this.View.OpenParameter.GetParameter("SourceBillNo") as string;

            // 根据参数加载数据
            LoadBOMList(sourceBillId);
        }
    }

    /// <summary>
    /// 确认按钮:将选择的数据返回父页面
    /// </summary>
    public override void BarItemClick(BarItemClickEventArgs e)
    {
        base.BarItemClick(e);

        if (e.BarItemKey == "F_PAEZ_Confirm")
        {
            string selectedBOMId = this.View.Model.GetValue("F_PAEZ_SelectedBOM")?.ToString();
            if (string.IsNullOrEmpty(selectedBOMId))
            {
                this.View.ShowErrMessage("请先选择一个BOM!");
                return;
            }

            // 构造返回数据
            DynamicObject returnData = GetBOMData(selectedBOMId);

            // 返回数据并关闭弹窗
            this.View.ReturnData = returnData;
            this.View.ReturnToParentWindow(true);  // true表示确认,false表示取消
            this.View.Close();
        }
    }
}

2.3 动态表单的返回值处理

父页面通过 ClosedCallBack 或回调委托接收返回值:

csharp 复制代码
/// <summary>
/// 通过ClosedCallBack接收子页面返回值
/// </summary>
public override void ClosedCallBack(ClosedCallBackEventArgs e)
{
    base.ClosedCallBack(e);

    // e.ReturnData 是子页面返回的数据
    if (e.ReturnData != null)
    {
        DynamicObject returnData = e.ReturnData as DynamicObject;
        if (returnData != null)
        {
            string selectedValue = returnData["FID"]?.ToString();
            this.View.Model.SetValue("F_PAEZ_SelectedValue", selectedValue);
            this.View.UpdateView("F_PAEZ_Header");
        }
    }
}

2.4 确认对话框与消息弹窗

csharp 复制代码
/// <summary>
/// 显示确认对话框
/// </summary>
public void ShowConfirmDialog()
{
    // 方式1:使用 ShowConfirmDialog(同步)
    bool confirmed = this.View.ShowConfirmDialog("确定要执行此操作吗?");
    if (confirmed)
    {
        // 执行操作
        this.View.ShowMessage("操作已执行");
    }

    // 方式2:使用 ShowConfirmDialog(带回调)
    this.View.ShowConfirmDialog("确定要删除此记录吗?", (result) =>
    {
        if (result == MessageBoxResult.OK)
        {
            // 执行删除
        }
        else
        {
            this.View.ShowMessage("已取消删除");
        }
    });
}

/// <summary>
/// 显示消息弹窗
/// </summary>
public void ShowMessages()
{
    // 普通消息
    this.View.ShowMessage("操作成功完成!");

    // 信息提示
    this.View.ShowInformation("请先选择物料");

    // 错误提示
    this.View.ShowErrMessage("保存失败:数量不能为负数!");

    // 警告提示
    this.View.ShowWarnningMessage("金额超过预算,请确认!");
}

第3章:操作拦截与事务控制

表单插件提供了多层次的操作拦截能力,从操作前校验到操作后处理,从事务内到事务外,覆盖了业务操作的完整生命周期。

3.1 操作拦截的层次

事件方法 触发时机 事务状态 用途
BeforeDoOperation 操作执行前 事务外 参数校验、权限检查,可取消
OnPrepareOperationServiceOption 准备服务参数时 事务外 修改操作选项
BeforeExecuteOperationTransaction 事务开始前 事务外 前置业务校验
AfterExecuteOperationTransaction 事务提交后 事务外 后置处理(日志、消息)
AfterDoOperation 操作完成后 事务外 获取操作结果

3.2 BeforeDoOperation 与 AfterDoOperation

csharp 复制代码
/// <summary>
/// 操作执行前(可拦截)
/// </summary>
public override void BeforeDoOperation(BeforeDoOperationEventArgs e)
{
    base.BeforeDoOperation(e);

    string operationId = e.Operation.FormOperation.OperationId;

    // 拦截保存操作
    if (operationId == "Save")
    {
        // 校验:单据体不能为空
        int entryCount = this.View.Model.GetEntryRowCount("F_PAEZ_Entry");
        if (entryCount == 0)
        {
            this.View.ShowErrMessage("请至少录入一行明细!");
            e.Cancel = true;
            return;
        }

        // 校验:金额不能为负数
        for (int i = 0; i < entryCount; i++)
        {
            decimal amount = Convert.ToDecimal(this.View.Model.GetValue("F_PAEZ_Amount", i) ?? 0);
            if (amount < 0)
            {
                this.View.ShowErrMessage($"第{i + 1}行金额不能为负数!");
                e.Cancel = true;
                return;
            }
        }
    }

    // 拦截提交操作
    if (operationId == "Submit")
    {
        string status = this.View.Model.GetValue("F_PAEZ_Status")?.ToString();
        if (status != "SAVED" && status != "DRAFT")
        {
            this.View.ShowErrMessage("只有草稿或已保存状态的单据才能提交!");
            e.Cancel = true;
        }
    }

    // 拦截审核操作
    if (operationId == "Audit")
    {
        decimal totalAmount = Convert.ToDecimal(this.View.Model.GetValue("F_PAEZ_TotalAmount") ?? 0);
        if (totalAmount > 1000000)
        {
            bool confirmed = this.View.ShowConfirmDialog("金额超过100万,确定要审核吗?");
            if (!confirmed)
            {
                e.Cancel = true;
            }
        }
    }
}

/// <summary>
/// 操作执行后
/// </summary>
public override void AfterDoOperation(AfterDoOperationEventArgs e)
{
    base.AfterDoOperation(e);

    string operationId = e.Operation.FormOperation.OperationId;

    if (operationId == "Save" && e.OperationResult.IsSuccess)
    {
        // 保存成功:记录日志
        string billNo = this.View.Model.GetValue("FBILLNO")?.ToString();
        WriteOperationLog("SAVE", billNo, "保存成功");

        // 更新状态
        this.View.Model.SetValue("F_PAEZ_Status", "SAVED");
        this.View.UpdateView("F_PAEZ_Header");
    }

    if (operationId == "Audit" && e.OperationResult.IsSuccess)
    {
        // 审核成功:发送消息通知
        string billNo = this.View.Model.GetValue("FBILLNO")?.ToString();
        SendNotification(billNo, "审核通过");
    }
}

3.3 事务内操作:OnPrepareOperationServiceOption

OnPrepareOperationServiceOption 在操作服务选项被构建时触发,可以修改操作行为 ,如设置批量操作模式 、忽略某些校验等。

csharp 复制代码
using Kingdee.BOS.App.Data;

/// <summary>
/// 准备操作服务选项(事务内)
/// </summary>
public override void OnPrepareOperationServiceOption(PrepareOperationServiceOptionArgs e)
{
    base.OnPrepareOperationServiceOption(e);

    // 获取操作选项
    BOSOption option = e.Option;

    // 场景1:批量操作模式(忽略部分校验)
    if (e.Operation.FormOperation.OperationId == "Save")
    {
        // 启用批量保存模式,忽略部分字段校验
        option.SetOption("BATCH_SAVE_MODE", true);
    }

    // 场景2:忽略必录校验(谨慎使用)
    if (e.Operation.FormOperation.OperationId == "Submit")
    {
        // 忽略某些字段的必录校验
        option.SetIgnoreRequiredCheckFields(new List<string> { "F_PAEZ_Remarks" });
    }

    // 场景3:设置事务超时时间
    option.SetOption("TransactionTimeout", 300);  // 300秒
}

3.4 操作选项的使用场景

csharp 复制代码
/// <summary>
/// 通过操作选项控制操作行为
/// </summary>
public void CustomOperationOptions()
{
    // 创建操作选项
    BOSOption option = new BOSOption();

    // 1. 启用批量模式(保存时忽略某些校验)
    option.SetOption("BATCH_SAVE_MODE", true);

    // 2. 忽略必录校验
    option.SetIgnoreRequiredCheckFields(new List<string> { "F_PAEZ_Remarks" });

    // 3. 设置忽略哪些字段的校验(值更新)
    option.SetIgnoreFieldUpdateServices(new List<string> { "F_PAEZ_Qty" });

    // 4. 设置事务超时
    option.SetOption("TransactionTimeout", 300);

    // 5. 设置操作是否记录日志
    option.SetOption("IsLog", true);

    // 6. 执行操作时传入选项
    this.View.InvokeFormOperation("Save", option);
}

3.5 在事务中执行自定义操作

当需要在保存操作的事务中执行自定义数据库操作时,应使用 BeforeExecuteOperationTransaction 事件:

csharp 复制代码
/// <summary>
/// 事务执行前(在事务内执行)
/// </summary>
public override void BeforeExecuteOperationTransaction(BeforeExecuteOperationTransactionArgs e)
{
    base.BeforeExecuteOperationTransaction(e);

    if (e.Operation.FormOperation.OperationId == "Save")
    {
        // 在事务内执行自定义更新
        // 注意:此操作会与保存操作在同一事务中,如果失败,整个事务回滚
        string ksql = @"
            UPDATE T_BD_MATERIAL 
            SET F_LAST_USE_DATE = @UseDate 
            WHERE FID = @MaterialId
        ";

        // 获取当前单据的物料列表
        int entryCount = this.View.Model.GetEntryRowCount("F_PAEZ_Entry");
        for (int i = 0; i < entryCount; i++)
        {
            string materialId = this.View.Model.GetValue("F_PAEZ_MaterialId", i)?.ToString();
            if (!string.IsNullOrEmpty(materialId))
            {
                List<SqlParam> parameters = new List<SqlParam>
                {
                    new SqlParam("@UseDate", SqlDbType.DateTime, DateTime.Now),
                    new SqlParam("@MaterialId", SqlDbType.BigInt, materialId)
                };
                DBServiceHelper.ExecuteNonQuery(this.Context, ksql, parameters.ToArray());
            }
        }
    }
}

⚠️ 重要提醒 :在 BeforeExecuteOperationTransaction 中执行的操作与当前单据的保存处于同一事务 中。如果这部分逻辑失败,整个操作(包括单据保存)都会回滚。适合需要强一致性的业务场景。

第4章:性能优化技巧

表单插件虽然功能强大,但如果不注意性能,很容易成为系统的瓶颈。本章将介绍表单插件开发中常见的性能优化技巧。

4.1 减少不必要的数据库查询

csharp 复制代码
/// <summary>
/// 错误示例:在循环中查询数据库(N+1问题)
/// </summary>
public void BadExample_QueryInLoop()
{
    int entryCount = this.View.Model.GetEntryRowCount("F_PAEZ_Entry");
    for (int i = 0; i < entryCount; i++)
    {
        string materialId = this.View.Model.GetValue("F_PAEZ_MaterialId", i)?.ToString();
        // ❌ 每次循环都查询数据库
        DynamicObject material = QueryServiceHelper.QueryOne("T_BD_MATERIAL", "*", 
            new QFilter[] { new QFilter("FID", QFilter.Operator.Equals, materialId) });
        // 处理物料数据...
    }
}

/// <summary>
/// 正确示例:批量查询(1次查询)
/// </summary>
public void GoodExample_BatchQuery()
{
    int entryCount = this.View.Model.GetEntryRowCount("F_PAEZ_Entry");
    
    // 1. 收集所有物料ID
    List<string> materialIds = new List<string>();
    for (int i = 0; i < entryCount; i++)
    {
        string id = this.View.Model.GetValue("F_PAEZ_MaterialId", i)?.ToString();
        if (!string.IsNullOrEmpty(id)) materialIds.Add(id);
    }
    
    if (materialIds.Count == 0) return;
    
    // 2. 一次查询所有物料
    string ksql = $@"
        SELECT FID, FNUMBER, FNAME, FSPECIFICATION, FPRICE 
        FROM T_BD_MATERIAL 
        WHERE FID IN ({string.Join(",", materialIds)})
    ";
    
    DataTable dt = DBServiceHelper.ExecuteDataSet(this.Context, ksql).Tables[0];
    
    // 3. 构建字典便于快速查找
    Dictionary<string, DataRow> materialDict = new Dictionary<string, DataRow>();
    foreach (DataRow row in dt.Rows)
    {
        materialDict[row["FID"].ToString()] = row;
    }
    
    // 4. 使用字典数据
    for (int i = 0; i < entryCount; i++)
    {
        string materialId = this.View.Model.GetValue("F_PAEZ_MaterialId", i)?.ToString();
        if (materialDict.TryGetValue(materialId, out DataRow materialRow))
        {
            this.View.Model.SetValue("F_PAEZ_Price", Convert.ToDecimal(materialRow["FPRICE"]), i);
        }
    }
}

4.2 合理使用缓存

对于频繁访问且不常变化的数据(如基础资料),可以使用缓存:

csharp 复制代码
/// <summary>
/// 使用缓存提高查询效率
/// </summary>
private DynamicObject GetCachedMaterial(string materialId)
{
    if (string.IsNullOrEmpty(materialId)) return null;
    
    // 构建缓存Key
    string cacheKey = $"MaterialCache_{materialId}";
    
    // 尝试从缓存获取
    DynamicObject cached = CacheManager.GetCacheObject<DynamicObject>(cacheKey);
    if (cached != null)
    {
        return cached;
    }
    
    // 缓存未命中,查询数据库
    DynamicObject material = QueryServiceHelper.QueryOne(
        "T_BD_MATERIAL",
        "FID, FNUMBER, FNAME, FSPECIFICATION, FPRICE",
        new QFilter[] { new QFilter("FID", QFilter.Operator.Equals, materialId) }
    );
    
    if (material != null)
    {
        // 写入缓存(有效期10分钟)
        CacheManager.SetCacheObject(cacheKey, material, 10 * 60);
    }
    
    return material;
}

4.3 延迟加载与按需加载

csharp 复制代码
/// <summary>
/// 按需加载:只有特定条件下才加载数据
/// </summary>
public override void AfterBindData(EventArgs e)
{
    base.AfterBindData(e);
    
    // 只在编辑模式下加载扩展数据
    if (this.View.IsCardView && !this.View.IsNew)
    {
        string status = this.View.Model.GetValue("F_PAEZ_Status")?.ToString();
        
        // 只有已审核的单据才加载审批历史
        if (status == "AUDITED")
        {
            LoadApprovalHistory();
        }
    }
}

private void LoadApprovalHistory()
{
    // 异步或延迟加载审批历史
    // 避免阻塞主界面渲染
    System.Threading.Tasks.Task.Run(() =>
    {
        string billId = this.View.Model.GetPKValue().ToString();
        string ksql = @"
            SELECT F_OPERATOR, F_OPERATE_TIME, F_ACTION 
            FROM T_PAEZ_APPROVAL_LOG 
            WHERE F_BILL_ID = @BillId
            ORDER BY F_OPERATE_TIME DESC
        ";
        // 执行查询并更新界面...
    });
}

4.4 避免在AfterBindData中大量修改数据

AfterBindData 每次数据绑定都会触发,如果在此事件中大量修改数据,会导致性能问题。

csharp 复制代码
/// <summary>
/// 错误示例:在AfterBindData中大量修改数据
/// </summary>
public override void AfterBindData(EventArgs e)
{
    base.AfterBindData(e);
    // ❌ 每次绑定都重新计算大量数据
    RecalculateAllAmounts();
}

/// <summary>
/// 正确示例:使用标志避免重复计算
/// </summary>
private bool _isCalculated = false;

public override void AfterBindData(EventArgs e)
{
    base.AfterBindData(e);
    
    // 只在第一次绑定时计算
    if (!_isCalculated)
    {
        RecalculateAllAmounts();
        _isCalculated = true;
    }
}

第5章:综合实战示例

5.1 场景描述

开发一个"采购申请单"的高级扩展插件,包含以下功能:

  1. F7物料选择时,根据供应商动态过滤;
  2. 物料选择后自动填充规格、单价、税率;
  3. 点击"选择BOM"按钮弹出动态表单选择BOM,自动填充物料明细;
  4. 保存前校验:物料不重复、金额非负、供应商与物料匹配;
  5. 审核前校验:金额超过50万需要二次确认;
  6. 审核通过后自动更新物料最后使用日期;
  7. 所有操作记录审计日志。

5.2 完整代码实现

csharp 复制代码
using Kingdee.BOS;
using Kingdee.BOS.App.Data;
using Kingdee.BOS.Core.Bill.PlugIn;
using Kingdee.BOS.Core.DynamicForm.PlugIn.Args;
using Kingdee.BOS.Orm.DataEntity;
using Kingdee.BOS.ServiceHelper;
using Kingdee.BOS.Util;
using System;
using System.Collections.Generic;
using System.Data;

namespace Demo.K3.SCM.Purchase.Business.PlugIn
{
    [Description("采购申请单高级扩展插件")]
    [HotUpdate]
    public class PurchaseReqAdvancedPlugIn : AbstractBillPlugIn
    {
        #region 常量定义

        private const string KEY_ENTRY = "F_PAEZ_Entry";
        private const string KEY_MATERIAL = "F_PAEZ_MaterialId";
        private const string KEY_SUPPLIER = "F_PAEZ_SupplierId";
        private const string KEY_QTY = "F_PAEZ_Qty";
        private const string KEY_PRICE = "F_PAEZ_Price";
        private const string KEY_AMOUNT = "F_PAEZ_Amount";
        private const string KEY_TAX_RATE = "F_PAEZ_TaxRate";
        private const string KEY_TAXED_AMOUNT = "F_PAEZ_TaxedAmount";
        private const string KEY_TOTAL_AMOUNT = "F_PAEZ_TotalAmount";
        private const string KEY_SPECIFICATION = "F_PAEZ_Specification";
        private const string KEY_STATUS = "F_PAEZ_Status";

        #endregion

        #region F7控制

        /// <summary>
        /// F7打开前:根据供应商动态过滤物料
        /// </summary>
        public override void BeforeF7Select(BeforeF7SelectEventArgs e)
        {
            base.BeforeF7Select(e);

            if (e.FieldKey == KEY_MATERIAL)
            {
                string supplierId = this.View.Model.GetValue(KEY_SUPPLIER)?.ToString();
                if (!string.IsNullOrEmpty(supplierId))
                {
                    // 只显示该供应商可提供的物料
                    e.Filter = $"FUseState = 'A' AND FDefaultSupplierId = '{supplierId}'";
                }
                else
                {
                    e.Filter = "FUseState = 'A'";
                }
            }
        }

        /// <summary>
        /// F7选择后:自动填充物料信息
        /// </summary>
        public override void AfterF7Select(AfterF7SelectEventArgs e)
        {
            base.AfterF7Select(e);

            if (e.FieldKey == KEY_MATERIAL && e.RowIndex >= 0)
            {
                DynamicObject material = e.ReturnData as DynamicObject;
                if (material != null)
                {
                    int row = e.RowIndex;
                    this.View.Model.SetValue(KEY_SPECIFICATION, material["FSpecification"], row);
                    this.View.Model.SetValue(KEY_PRICE, material["FPrice"], row);
                    this.View.Model.SetValue(KEY_TAX_RATE, material["FTaxRate"] ?? 0.13m, row);
                    
                    // 重算金额
                    RecalculateRowAmount(row);
                }
            }
        }

        #endregion

        #region 字段联动

        /// <summary>
        /// 字段值变更
        /// </summary>
        public override void DataChanged(DataChangedEventArgs e)
        {
            base.DataChanged(e);

            if ((e.Field.Key == KEY_QTY || e.Field.Key == KEY_PRICE || e.Field.Key == KEY_TAX_RATE) 
                && e.RowIndex >= 0)
            {
                RecalculateRowAmount(e.RowIndex);
            }
        }

        /// <summary>
        /// 重新计算行金额
        /// </summary>
        private void RecalculateRowAmount(int rowIndex)
        {
            decimal qty = Convert.ToDecimal(this.View.Model.GetValue(KEY_QTY, rowIndex) ?? 0);
            decimal price = Convert.ToDecimal(this.View.Model.GetValue(KEY_PRICE, rowIndex) ?? 0);
            decimal taxRate = Convert.ToDecimal(this.View.Model.GetValue(KEY_TAX_RATE, rowIndex) ?? 0.13m);

            decimal amount = qty * price;
            decimal taxedAmount = amount * (1 + taxRate);

            this.View.Model.SetValue(KEY_AMOUNT, amount, rowIndex);
            this.View.Model.SetValue(KEY_TAXED_AMOUNT, taxedAmount, rowIndex);

            // 更新总金额
            UpdateTotalAmount();
            this.View.UpdateView(KEY_ENTRY);
        }

        /// <summary>
        /// 更新单据头总金额
        /// </summary>
        private void UpdateTotalAmount()
        {
            decimal total = 0;
            int count = this.View.Model.GetEntryRowCount(KEY_ENTRY);
            for (int i = 0; i < count; i++)
            {
                decimal amount = Convert.ToDecimal(this.View.Model.GetValue(KEY_TAXED_AMOUNT, i) ?? 0);
                total += amount;
            }
            this.View.Model.SetValue(KEY_TOTAL_AMOUNT, total);
        }

        #endregion

        #region BOM选择

        /// <summary>
        /// 自定义按钮:选择BOM
        /// </summary>
        public override void BarItemClick(BarItemClickEventArgs e)
        {
            base.BarItemClick(e);

            if (e.BarItemKey == "F_PAEZ_SelectBOM")
            {
                // 检查供应商是否已选
                string supplierId = this.View.Model.GetValue(KEY_SUPPLIER)?.ToString();
                if (string.IsNullOrEmpty(supplierId))
                {
                    this.View.ShowErrMessage("请先选择供应商!");
                    return;
                }

                OpenParameter parameter = new OpenParameter();
                parameter.FormId = "PAEZ_BOMSelector";
                parameter.PageId = "F_PAEZ_BOMSelectorPage";
                parameter.Put("SupplierId", supplierId);
                parameter.Put("SourceBillId", this.View.Model.GetPKValue());

                this.View.ShowForm(parameter, OnBOMSelected);
            }
        }

        /// <summary>
        /// BOM选择回调
        /// </summary>
        private void OnBOMSelected(FormResult result)
        {
            if (result.IsLookup && result.ReturnData != null)
            {
                DynamicObject selectedBOM = result.ReturnData as DynamicObject;
                if (selectedBOM != null)
                {
                    FillBOMEntry(selectedBOM);
                    this.View.ShowMessage($"已选择BOM:{selectedBOM["FNAME"]}");
                }
            }
        }

        /// <summary>
        /// 根据BOM填充物料明细
        /// </summary>
        private void FillBOMEntry(DynamicObject bom)
        {
            string bomId = bom["FID"]?.ToString();
            if (string.IsNullOrEmpty(bomId)) return;

            // 查询BOM明细
            string ksql = @"
                SELECT F_MATERIAL_ID, F_QTY, F_PRICE 
                FROM T_PAEZ_BOM_DETAIL 
                WHERE F_BOM_ID = @BomId
            ";

            DataTable dt = DBServiceHelper.ExecuteDataSet(
                this.Context,
                ksql,
                new SqlParam[] { new SqlParam("@BomId", SqlDbType.NVarChar, bomId) }
            ).Tables[0];

            // 清空现有明细
            int currentCount = this.View.Model.GetEntryRowCount(KEY_ENTRY);
            for (int i = currentCount - 1; i >= 0; i--)
            {
                this.View.Model.DeleteEntryRow(KEY_ENTRY, i);
            }

            // 插入BOM明细
            foreach (DataRow row in dt.Rows)
            {
                int newRow = this.View.Model.CreateNewEntryRow(KEY_ENTRY);
                this.View.Model.SetValue(KEY_MATERIAL, row["F_MATERIAL_ID"].ToString(), newRow);
                this.View.Model.SetValue(KEY_QTY, Convert.ToDecimal(row["F_QTY"]), newRow);
                this.View.Model.SetValue(KEY_PRICE, Convert.ToDecimal(row["F_PRICE"]), newRow);
                RecalculateRowAmount(newRow);
            }

            this.View.UpdateView(KEY_ENTRY);
        }

        #endregion

        #region 操作拦截与事务控制

        /// <summary>
        /// 保存前校验
        /// </summary>
        public override void BeforeSave(BeforeSaveEventArgs e)
        {
            base.BeforeSave(e);

            int entryCount = this.View.Model.GetEntryRowCount(KEY_ENTRY);

            // 1. 校验:明细不能为空
            if (entryCount == 0)
            {
                this.View.ShowErrMessage("请至少录入一行物料明细!");
                e.Cancel = true;
                return;
            }

            // 2. 校验:物料不重复
            HashSet<string> materialSet = new HashSet<string>();
            for (int i = 0; i < entryCount; i++)
            {
                string materialId = this.View.Model.GetValue(KEY_MATERIAL, i)?.ToString();
                if (!string.IsNullOrEmpty(materialId))
                {
                    if (materialSet.Contains(materialId))
                    {
                        this.View.ShowErrMessage($"第{i + 1}行物料重复!");
                        e.Cancel = true;
                        return;
                    }
                    materialSet.Add(materialId);
                }
            }

            // 3. 校验:供应商与物料匹配
            string supplierId = this.View.Model.GetValue(KEY_SUPPLIER)?.ToString();
            if (!string.IsNullOrEmpty(supplierId))
            {
                for (int i = 0; i < entryCount; i++)
                {
                    string materialId = this.View.Model.GetValue(KEY_MATERIAL, i)?.ToString();
                    if (!string.IsNullOrEmpty(materialId) && !IsMaterialMatchSupplier(materialId, supplierId))
                    {
                        this.View.ShowErrMessage($"第{i + 1}行物料与该供应商不匹配!");
                        e.Cancel = true;
                        return;
                    }
                }
            }
        }

        /// <summary>
        /// 检查物料与供应商是否匹配
        /// </summary>
        private bool IsMaterialMatchSupplier(string materialId, string supplierId)
        {
            string ksql = @"
                SELECT COUNT(1) 
                FROM T_BD_MATERIAL 
                WHERE FID = @MaterialId AND FDefaultSupplierId = @SupplierId
            ";

            DataTable dt = DBServiceHelper.ExecuteDataSet(
                this.Context,
                ksql,
                new SqlParam[] {
                    new SqlParam("@MaterialId", SqlDbType.BigInt, materialId),
                    new SqlParam("@SupplierId", SqlDbType.BigInt, supplierId)
                }
            ).Tables[0];

            return Convert.ToInt32(dt.Rows[0][0]) > 0;
        }

        /// <summary>
        /// 事务内操作:更新物料最后使用日期
        /// </summary>
        public override void BeforeExecuteOperationTransaction(
            BeforeExecuteOperationTransactionArgs e)
        {
            base.BeforeExecuteOperationTransaction(e);

            if (e.Operation.FormOperation.OperationId == "Audit")
            {
                // 审核通过后,在事务内更新物料最后使用日期
                int entryCount = this.View.Model.GetEntryRowCount(KEY_ENTRY);
                for (int i = 0; i < entryCount; i++)
                {
                    string materialId = this.View.Model.GetValue(KEY_MATERIAL, i)?.ToString();
                    if (!string.IsNullOrEmpty(materialId))
                    {
                        string ksql = @"
                            UPDATE T_BD_MATERIAL 
                            SET F_LAST_USE_DATE = @UseDate 
                            WHERE FID = @MaterialId
                        ";
                        List<SqlParam> parameters = new List<SqlParam>
                        {
                            new SqlParam("@UseDate", SqlDbType.DateTime, DateTime.Now),
                            new SqlParam("@MaterialId", SqlDbType.BigInt, materialId)
                        };
                        DBServiceHelper.ExecuteNonQuery(this.Context, ksql, parameters.ToArray());
                    }
                }
            }
        }

        #endregion

        #region 审计日志

        /// <summary>
        /// 操作后:记录审计日志
        /// </summary>
        public override void AfterDoOperation(AfterDoOperationEventArgs e)
        {
            base.AfterDoOperation(e);

            if (e.OperationResult.IsSuccess)
            {
                string billNo = this.View.Model.GetValue("FBILLNO")?.ToString();
                string operationId = e.Operation.FormOperation.OperationId;

                string action = operationId switch
                {
                    "Save" => "保存",
                    "Submit" => "提交",
                    "Audit" => "审核",
                    "UnAudit" => "反审核",
                    "Delete" => "删除",
                    _ => operationId
                };

                WriteAuditLog(billNo, action);
            }
        }

        /// <summary>
        /// 写入审计日志
        /// </summary>
        private void WriteAuditLog(string billNo, string action)
        {
            string ksql = @"
                INSERT INTO T_PAEZ_AUDIT_LOG 
                (F_BILL_NO, F_ACTION, F_OPERATOR, F_OPERATE_TIME, F_REMARK) 
                VALUES 
                (@BillNo, @Action, @Operator, @OperateTime, @Remark)
            ";

            List<SqlParam> parameters = new List<SqlParam>
            {
                new SqlParam("@BillNo", SqlDbType.NVarChar, billNo),
                new SqlParam("@Action", SqlDbType.NVarChar, action),
                new SqlParam("@Operator", SqlDbType.NVarChar, this.Context.UserName),
                new SqlParam("@OperateTime", SqlDbType.DateTime, DateTime.Now),
                new SqlParam("@Remark", SqlDbType.NVarChar, $"操作人:{this.Context.UserName}")
            };

            DBServiceHelper.ExecuteNonQuery(this.Context, ksql, parameters.ToArray());
        }

        #endregion

        #region 确认对话框

        /// <summary>
        /// 审核前二次确认
        /// </summary>
        public override void BeforeDoOperation(BeforeDoOperationEventArgs e)
        {
            base.BeforeDoOperation(e);

            if (e.Operation.FormOperation.OperationId == "Audit")
            {
                decimal totalAmount = Convert.ToDecimal(
                    this.View.Model.GetValue(KEY_TOTAL_AMOUNT) ?? 0
                );
                if (totalAmount > 500000)
                {
                    bool confirmed = this.View.ShowConfirmDialog(
                        $"金额 {totalAmount:N2} 超过50万,确定要审核吗?",
                        MessageBoxType.Question
                    );
                    if (!confirmed)
                    {
                        e.Cancel = true;
                    }
                }
            }
        }

        #endregion
    }
}

本篇小结

本篇完成了金蝶云星空企业版表单插件开发的高级应用讲解:

章节 核心收获
第1章 掌握F7字段的三层数据结构、BeforeF7Select动态过滤(供应商/组织/排除已选)、AfterF7Select自动填充、F7字段赋值方法
第2章 掌握动态表单的打开(OpenParameter)、参数传递、回调处理(ClosedCallBack)、确认对话框与消息弹窗
第3章 掌握操作拦截的多层架构(BeforeDoOperation/OnPrepareOperationServiceOption/BeforeExecuteOperationTransaction/AfterDoOperation)、事务内自定义操作
第4章 掌握性能优化技巧:批量查询替代循环查询、缓存使用、延迟加载、避免AfterBindData中大量修改数据
第5章 综合实战:F7过滤+自动填充+BOM选择+多重校验+事务操作+审计日志的完整实现

下一篇预告 :第三篇将深入讲解表单插件的数据转换与下推------从目标单据数据获取、转换规则插件开发,到反写规则的完整实现,并结合WebAPI调用、外部系统集成等企业级场景,打造一个完整的跨单据业务流程。

相关推荐
小懿互联集成平台15 天前
金蝶云星空与极风 WMS 业务协同对接实施方案
数据集成·金蝶云星空·数据对接·小懿互联·极风wms·业仓一体化
小懿互联集成平台2 个月前
金蝶云星空与兴业银行银企直连集成对接实施方案
网络·金蝶云星空·小懿互联·银企直联·兴业银行·数据对接方案
tryCbest2 个月前
金蝶云星空开发~第三篇:插件篇——C#插件开发与事件体系
金蝶云星空·kingdee
小懿互联集成平台3 个月前
企业微信与金蝶云星空业财审批一体化对接实施方案
数据分析·企业微信·金蝶云星空·小懿互联·数据集成对接·采购申请审批·费用报销审批
小懿互联集成平台4 个月前
金蝶云星空与钉钉OA审批对接-构建一体化财务付款管理体系
钉钉·金蝶云星空·数据对接·小懿互联·构建一体化财务付款
小懿互联集成平台4 个月前
金蝶云星空与赛狐跨境电商ERP系统数据互通对接
大数据·金蝶云星空·数据对接·小懿互联·赛狐erp
金蝶LOG5 个月前
【金蝶云星空】出纳做账-应收票据背书退回
金蝶·erp·进销存系统·金蝶云星空
金蝶LOG7 个月前
【金蝶云星空】如何新增组织机构(新公司)
金蝶·erp·进销存系统·金蝶云星空
小懿互联集成平台7 个月前
金蝶云星空与找钢网对接实施方案-实现业财融合一体
业财融合·金蝶云星空·数据对接·小懿互联·找钢网