src/Workflows/Upsert-XrmBusinessProcessFlow.ps1

<#
    .SYNOPSIS
    Create or update a Business Process Flow (BPF) definition in Microsoft Dataverse.

    .DESCRIPTION
    Upsert a workflow record (category=4, BPF) by its workflow Id. When the record already exists in Dataverse,
    the cmdlet updates xaml/name/processorder/... via Update-XrmRecord. Otherwise it creates a new workflow
    using the provided Id (Add-XrmRecord).

    Localized names are persisted via the SetLocLabels message (workflow.name) when bilingual labels are
    supplied via -Labels.

    When -SolutionUniqueName is provided, the workflow is registered as a component of the target unmanaged
    solution (component type 29 - Workflow).

    When -Activate is set (default $true), the workflow is activated at the end of the operation via
    Enable-XrmWorkflow. Activation is what causes Dataverse to materialise the BPF instance entity
    (e.g. aaa_bpf_opportunitytocontractprocess) - this entity must NOT be created manually; Dataverse
    generates it automatically on first activation of the BPF.

    TODO: -Roles array is accepted for forward compatibility but role-to-process assignment
    (processroleassignment XML / Privilege records) is not handled in this iteration.

    .PARAMETER XrmClient
    Xrm connector initialized to target instance. Use latest one by default. (Dataverse ServiceClient)

    .PARAMETER Id
    Workflow Id (used as the upsert key - the BPF GUID consumed by the generated XAML class name).

    .PARAMETER UniqueName
    Workflow unique name (publisher-prefixed, e.g. aaa_opportunitytocontractprocess).

    .PARAMETER PrimaryEntity
    Logical name of the entity the BPF is anchored on (e.g. aaa_opportunity).

    .PARAMETER Name
    Workflow display name. Used when -Labels is not provided.

    .PARAMETER Labels
    Hashtable of language code to display name. Persisted as real translations via SetLocLabels.
    Example: @{ 1033 = "Opportunity to Contract"; 1036 = "Opportunite vers Contrat" }.

    .PARAMETER LanguageCode
    Language code used to pick the stored 'name' from -Labels. Default: 1033.

    .PARAMETER Description
    Workflow description.

    .PARAMETER Xaml
    Workflow XAML definition (BPF activity tree). MUST embed the workflow Id (without dashes) in the
    Activity x:Class attribute.

    .PARAMETER Category
    Workflow category. 4 = Business Process Flow. Default: 4.

    .PARAMETER Type
    Workflow type. 1 = Definition. Default: 1.

    .PARAMETER Mode
    Workflow mode. 0 = Background. Default: 0.

    .PARAMETER Scope
    Workflow scope. 4 = Organization. Default: 4.

    .PARAMETER BusinessProcessType
    Business process type. 0 = Business Process Flow. Default: 0.

    .PARAMETER ProcessOrder
    Order of the process in the entity's BPF picker. Default: 1.

    .PARAMETER RunAs
    Run-as user code. 1 = Owner of the workflow record. Default: 1.

    .PARAMETER IsTransacted
    Whether the BPF runs in a transaction. Default: $true.

    .PARAMETER TriggerOnCreate
    Whether the BPF auto-starts on create of the primary entity. Default: $true.

    .PARAMETER IntroducedVersion
    Solution-introduced version stamp.

    .PARAMETER SolutionUniqueName
    Unmanaged solution unique name. When provided, the workflow is registered as a solution component (type 29).

    .PARAMETER Roles
    Array of security role unique names (currently not assigned - see TODO above).

    .PARAMETER Activate
    Activate the workflow after upsert. Default: $true.

    .OUTPUTS
    Microsoft.Xrm.Sdk.EntityReference. Reference to the upserted workflow record.

    .EXAMPLE
    $ref = Upsert-XrmBusinessProcessFlow -Id $processId -UniqueName "aaa_opportunitytocontractprocess" `
        -PrimaryEntity "aaa_opportunity" -Labels @{ 1033 = "Opportunity to Contract"; 1036 = "Opportunite vers Contrat" } `
        -Description "Opportunity BPF" -Xaml $xaml -SolutionUniqueName "svcmgr_workflows";
#>

function Upsert-XrmBusinessProcessFlow {
    [CmdletBinding(DefaultParameterSetName = "ByName")]
    [OutputType([Microsoft.Xrm.Sdk.EntityReference])]
    param
    (
        [Parameter(Mandatory = $false, ValueFromPipeline)]
        [Microsoft.PowerPlatform.Dataverse.Client.ServiceClient]
        $XrmClient = $Global:XrmClient,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [Guid]
        $Id,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $UniqueName,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $PrimaryEntity,

        [Parameter(Mandatory = $true, ParameterSetName = "ByName")]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ParameterSetName = "ByLabels")]
        [ValidateNotNullOrEmpty()]
        [Hashtable]
        $Labels,

        [Parameter(Mandatory = $false)]
        [int]
        $LanguageCode = 1033,

        [Parameter(Mandatory = $false)]
        [string]
        $Description,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Xaml,

        [Parameter(Mandatory = $false)]
        [int]
        $Category = 4,

        [Parameter(Mandatory = $false)]
        [int]
        $Type = 1,

        [Parameter(Mandatory = $false)]
        [int]
        $Mode = 0,

        [Parameter(Mandatory = $false)]
        [int]
        $Scope = 4,

        [Parameter(Mandatory = $false)]
        [int]
        $BusinessProcessType = 0,

        [Parameter(Mandatory = $false)]
        [int]
        $ProcessOrder = 1,

        [Parameter(Mandatory = $false)]
        [int]
        $RunAs = 1,

        [Parameter(Mandatory = $false)]
        [bool]
        $IsTransacted = $true,

        [Parameter(Mandatory = $false)]
        [bool]
        $TriggerOnCreate = $true,

        [Parameter(Mandatory = $false)]
        [string]
        $IntroducedVersion,

        [Parameter(Mandatory = $false)]
        [string]
        $SolutionUniqueName,

        [Parameter(Mandatory = $false)]
        [string[]]
        $Roles,

        [Parameter(Mandatory = $false)]
        [bool]
        $Activate = $true
    )
    begin {
        $StopWatch = [System.Diagnostics.Stopwatch]::StartNew();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Start -Parameters ($MyInvocation.MyCommand.Parameters);
    }
    process {

        if ($PSCmdlet.ParameterSetName -eq "ByLabels") {
            $Name = Get-XrmLabelText -Labels $Labels -LanguageCode $LanguageCode;
        }

        $attributes = @{
            "name"                = $Name;
            "uniquename"          = $UniqueName;
            "primaryentity"       = $PrimaryEntity;
            "xaml"                = $Xaml;
            "category"            = (New-XrmOptionSetValue -Value $Category);
            "type"                = (New-XrmOptionSetValue -Value $Type);
            "mode"                = (New-XrmOptionSetValue -Value $Mode);
            "scope"               = (New-XrmOptionSetValue -Value $Scope);
            "businessprocesstype" = (New-XrmOptionSetValue -Value $BusinessProcessType);
            "processorder"        = $ProcessOrder;
            "runas"               = (New-XrmOptionSetValue -Value $RunAs);
            "istransacted"        = $IsTransacted;
            "triggeroncreate"     = $TriggerOnCreate;
        };

        if ($PSBoundParameters.ContainsKey('Description')) {
            $attributes["description"] = $Description;
        }
        if ($PSBoundParameters.ContainsKey('IntroducedVersion')) {
            $attributes["introducedversion"] = $IntroducedVersion;
        }

        # Existence probe by workflow id - drives create vs update path.
        # We don't use the Upsert SDK message here because activation (Set-XrmRecordState) of a
        # newly created BPF must come AFTER the row exists, and pulling the existing flag lets us
        # keep the Add/Update distinction symmetric with the rest of the Workflows folder.
        $existing = $null;
        try {
            $existing = Get-XrmRecord -XrmClient $XrmClient -LogicalName "workflow" -Value $Id -Columns "workflowid", "statecode";
        }
        catch {
            $existing = $null;
        }

        if ($null -ne $existing) {
            $record = New-XrmEntity -LogicalName "workflow" -Id $Id -Attributes $attributes;
            Update-XrmRecord -XrmClient $XrmClient -Record $record | Out-Null;
        }
        else {
            $record = New-XrmEntity -LogicalName "workflow" -Id $Id -Attributes $attributes;
            Add-XrmRecord -XrmClient $XrmClient -Record $record | Out-Null;
        }

        $workflowReference = New-XrmEntityReference -LogicalName "workflow" -Id $Id;

        if ($PSCmdlet.ParameterSetName -eq "ByLabels") {
            Set-XrmLocalizedLabel -XrmClient $XrmClient -EntityMoniker $workflowReference -AttributeName "name" -Labels $Labels | Out-Null;
        }

        if ($PSBoundParameters.ContainsKey('SolutionUniqueName')) {
            # Workflow component type per Microsoft docs is 29.
            Add-XrmSolutionComponent -XrmClient $XrmClient -SolutionUniqueName $SolutionUniqueName -ComponentId $Id -ComponentType 29 -DoNotIncludeSubcomponents $false | Out-Null;
        }

        if ($Activate) {
            Enable-XrmWorkflow -XrmClient $XrmClient -WorkflowReference $workflowReference;
        }

        $workflowReference;
    }
    end {
        $StopWatch.Stop();
        Trace-XrmFunction -Name $MyInvocation.MyCommand.Name -Stage Stop -StopWatch $StopWatch;
    }
}

Export-ModuleMember -Function Upsert-XrmBusinessProcessFlow -Alias *;