Skip to content

ERP Integration Guide

This guide shows how to build an ERP connector that enriches components with live data, uploads BOMs, syncs project variables, and provides a parts search dialog. The example uses SQL Server, but the same pattern applies to SAP, REST APIs, or any other backend.

Which capabilities to implement

Capability Method Purpose
getComponentParameters GetComponentParameters Enrich components with ERP data (status, price, stock)
uploadBom UploadBom Push the schematic BOM to the ERP when the user closes the project
getProjectVariables GetProjectVariables Pull title-block data from the ERP when the file opens
setProjectVariables SetProjectVariables Push title-block changes back to the ERP when the file closes
getPartFamilyMembers GetPartFamilyMembers List variants in a part family

Start with getComponentParameters. Add others as needed.

Connect to the ERP

private SqlConnection _db = null!;

public override void Initialize(InitializeRequest request)
{
    var config = IniFile.Load(request.ConfigPath);
    string connStr = config["Database", "ConnectionString"];

    _db = new SqlConnection(connStr);
    _db.Open(); // verify connection at startup

    _log = new FileLogger(
        Path.Combine(Path.GetDirectoryName(request.ConfigPath)!, "acme-erp.log"));
}

GetComponentParameters

public override GetComponentParametersResponse GetComponentParameters(
    GetComponentParametersRequest request)
{
    const string sql = """
        SELECT ArticleCode, StatusCode, Description, PriceEUR,
               Manufacturer, ManufacturerPartNo, LeadTimeDays, StockQty
        FROM   Articles
        WHERE  ArticleCode = @code
        """;

    using var cmd = new SqlCommand(sql, _db);
    cmd.Parameters.AddWithValue("@code", request.ArticleCode);

    using var reader = cmd.ExecuteReader();
    if (!reader.Read())
        throw new PluginException("NOT_FOUND",
            $"Article '{request.ArticleCode}' not found in ERP.");

    return new GetComponentParametersResponse
    {
        ArticleCode      = reader.GetString(0),
        Status           = MapStatus(reader.GetString(1)),
        Description      = reader.GetString(2),
        Price            = reader.GetDouble(3),
        Currency         = "EUR",
        Manufacturer     = reader.GetString(4),
        ManufacturerPartNo = reader.GetString(5),
        LeadTimeDays     = reader.GetInt32(6),
        Stock            = reader.GetInt32(7),
    };
}

private static string MapStatus(string erpCode) => erpCode switch
{
    "REL" => "released",
    "DRF" => "draft",
    "OBS" => "obsolete",
    "BLK" => "blocked",
    _     => "draft",
};

Caching

If your ERP query is slow, cache results by article code for the duration of the session. Use ConcurrentDictionary<string, GetComponentParametersResponse> and a reasonable TTL (e.g., 5 minutes).

GetProjectVariables

Called when a file opens. Return the title-block fields for this project:

public override GetProjectVariablesResponse GetProjectVariables(
    GetProjectVariablesRequest request)
{
    if (request.ProjectId is null)
        throw new PluginException("VALIDATION_ERROR",
            "No project ID available. Cannot fetch project variables.");

    const string sql = """
        SELECT CustomerName, Revision, DrawingNumber, Description, ProjectManager
        FROM   Projects
        WHERE  ProjectId = @id
        """;

    using var cmd = new SqlCommand(sql, _db);
    cmd.Parameters.AddWithValue("@id", request.ProjectId);
    using var reader = cmd.ExecuteReader();

    if (!reader.Read())
        throw new PluginException("NOT_FOUND",
            $"Project '{request.ProjectId}' not found in ERP.");

    return new GetProjectVariablesResponse
    {
        Variables = new()
        {
            ["CustomerName"]    = reader.GetString(0),
            ["Revision"]        = reader.GetString(1),
            ["DrawingNumber"]   = reader.GetString(2),
            ["Description"]     = reader.GetString(3),
            ["ProjectManager"]  = reader.GetString(4),
        }
    };
}

SetProjectVariables

Called when a file closes. Push any changed title-block data back to the ERP:

public override void SetProjectVariables(SetProjectVariablesRequest request)
{
    if (request.ProjectId is null) return;

    const string sql = """
        UPDATE Projects
        SET    Revision = @rev, Description = @desc
        WHERE  ProjectId = @id
        """;

    using var cmd = new SqlCommand(sql, _db);
    cmd.Parameters.AddWithValue("@id",  request.ProjectId);
    cmd.Parameters.AddWithValue("@rev", request.Variables.GetValueOrDefault("Revision", ""));
    cmd.Parameters.AddWithValue("@desc",request.Variables.GetValueOrDefault("Description", ""));
    cmd.ExecuteNonQuery();
}

UploadBom

Called when the user closes the project (if auto-upload is enabled). The BOM is a flat or hierarchical list of BomItem objects:

public override UploadBomResponse UploadBom(UploadBomRequest request)
{
    if (request.Format == "file")
    {
        // HydroSym has already generated a BOM file — upload it as-is
        var file = request.ExportedFiles?.FirstOrDefault(f => f.Format == "xml")
            ?? throw new PluginException("VALIDATION_ERROR", "Expected an XML export file.");

        var bomId = _erp.UploadBomFile(request.ProjectId!, file.FilePath);
        return new UploadBomResponse { BomId = bomId };
    }

    // Structured BOM — insert line by line
    string bomId = _erp.BeginBom(request.ProjectId!);
    foreach (var item in request.Items ?? [])
    {
        _erp.AddBomItem(bomId, new ErpBomLine
        {
            Position   = item.Position,
            ArticleCode = item.ArticleCode,
            Quantity   = item.Quantity,
            Unit       = item.Unit ?? "pcs",
        });
    }
    _erp.CommitBom(bomId);

    return new UploadBomResponse
    {
        BomId   = bomId,
        Message = $"BOM uploaded with {request.Items?.Count ?? 0} items.",
    };
}

Adding a search menu item

Declare the item in GetInfo with ResultType = "component":

new MenuItem
{
    Id = "erp-search",
    Label = "&Search ERP Parts...",
    Location = "plugin",
    Icon = "search",
    ResultType = "component",
}

Implement OnMenuAction:

public override OnMenuActionResponse OnMenuAction(OnMenuActionRequest request)
{
    if (request.MenuItemId != "erp-search")
        throw new PluginException("NOT_FOUND", $"Unknown menu item: {request.MenuItemId}");

    var dialog = new ErpSearchDialog(_db)
    {
        Owner = NativeWindow.FromHandle(new IntPtr(request.ParentWindowHandle))
    };

    if (dialog.ShowDialog() != DialogResult.OK || dialog.SelectedArticle is null)
        return new OnMenuActionResponse(); // user cancelled

    var art = dialog.SelectedArticle;
    return new OnMenuActionResponse
    {
        ArticleCode        = art.ArticleCode,
        Description        = art.Description,
        Manufacturer       = art.Manufacturer,
        ManufacturerPartNo = art.ManufacturerPartNo,
    };
}

See Custom Search Dialog for the dialog implementation.

Complete example

A complete ERP connector sample is in the SDK repository at samples/AcmeErpConnector/. It includes: - SQL Server queries against a sample schema - A WinForms search dialog - An xUnit test suite with an in-memory SQLite backend - Integration test scripts for HydroSymPluginTester