Search
Close this search box.
Search
Close this search box.
Stand with Ukraine
Search
Close this search box.

Setting up an approval flow for custom DACs or creating a custom approval screen for standard DACs that already support approval flow can greatly enhance the efficiency of your processes. 

This guide provides a step-by-step approach to setting up approval flows for your custom DACs or creating a custom approval screen for standard DACs that already support approval workflows.

If you’re working with standard DACs, you can skip the first two sections and proceed directly to the “Add Approval Views to Your Graph” chapter.

1. Create an Assignment Map

To make your DAC capable of configuring the approval process, you need to add the IAssignedMap interface and implement the necessary fields.

using PX.Data;
using PX.Data.BQL;
using PX.Objects.EP;
using PX.SM;

namespace Sprinterra.ApprovalHowTo
{
    [PXCacheName("Setup")]
    public class Setup : IBqlTable, IAssignedMap
    {
        // Approval configuration
        #region IsActive
        [PXDBBool()]
        [PXDefault(false)]
        public virtual bool? IsActive { get; set; }
        public abstract class isActive : BqlBool.Field<isActive> { }
        #endregion IsActive

        #region AssignmentMapID
        [PXDBInt]
        [PXSelector(typeof(Search<EPAssignmentMap.assignmentMapID,
                            Where<EPAssignmentMap.entityType, Equal<assignmentMapID.approvableRecord>>>),
                    typeof(EPAssignmentMap.name),
                    SubstituteKey = typeof(EPAssignmentMap.name))]
        [PXUIField(DisplayName = "Approval Map")]
        public virtual int? AssignmentMapID { get; set; }
        public abstract class assignmentMapID : BqlInt.Field<assignmentMapID>
        {
            public class approvableRecord : BqlString.Constant<approvableRecord>
            {
                public approvableRecord() : base(typeof(ApprovableRecord).FullName) { }
            }
        }
        #endregion AssignmentMapID

        #region AssignmentNotificationID
        [PXDBInt]
        [PXSelector(typeof(Search<Notification.notificationID>),
                    typeof(Notification.name),
                    SubstituteKey = typeof(Notification.name))]
        [PXUIField(DisplayName = "Notification Template")]
        public virtual int? AssignmentNotificationID { get; set; }
        public abstract class assignmentNotificationID : BqlInt.Field<assignmentNotificationID> { };
        #endregion AssignmentNotificationID
    }
}

2. Create an Approvable DAC

For records that need to be approvable, add the IAssign interface. This will require defining the OwnerID and WorkgroupID fields. Additionally, the Approved, Rejected, and Hold fields are necessary for the EPApprovalAutomation view. Here’s an example:

using PX.Data;
using PX.Data.BQL;
using PX.Data.EP;
using PX.Objects.CR.MassProcess;
using PX.TM;
using System;

namespace Sprinterra.ApprovalHowTo
{
    [Serializable]
    [PXCacheName(nameof(ApprovableRecord))]
    [PXPrimaryGraph(typeof(ApprovableRecordMaint))]
    public class ApprovableRecord : IBqlTable, IAssign
    {
        // Other fields here, including key(s)

        // Approval
        #region Approved
        ///<summary>
        /// Specifies (if set to <c>true</c> that it has been approved by a responsible person and is in an Approved state now.
        /// </summary>
        [PXDBBool]
        [PXUIField(DisplayName = "Approve", Visibility = PXUIVisibility.Visible)]
        [PXDefault(false)]
        public virtual bool? Approved { get; set; }
        public abstract class approved : BqlBool.Field<approved> { }
        #endregion

        #region Rejected
        /// <summary>
        /// Specifies (if set to <c>true</c>) that it has been rejected by a responsible person.
        /// </summary>
        [PXDBBool]
        public virtual bool? Rejected { get; set; }
        public abstract class rejected : BqlBool.Field<rejected> { }
        #endregion

        #region Hold
        /// <summary>
        /// Specifies (if set to true) that it is On Hold
        /// </summary>
        [PXDBBool()]
        [PXUIField(DisplayName = "Hold", Visibility = PXUIVisibility.Visible)]
        [PXDefault(true)]
        public virtual bool? Hold { get; set; }
        public abstract class hold : BqlBool.Field<hold> { }
        #endregion

        #region OwnerID
        [Owner(typeof(workgroupID))]
        [PXMassUpdatableField]
        [PXMassMergableField]
        public virtual int? OwnerID { get; set; }
        public abstract class ownerID : BqlInt.Field<ownerID> { }
        #endregion

        #region WorkgroupID
        /// <summary>
        /// The ID of the workgroup which was assigned to approve the transaction.
        ///</summary>
        [PXInt]
        [PXSelector(typeof(Search<EPCompanyTree.workGroupID>), SubstituteKey = typeof(EPCompanyTree.description))]
        [PXUIField(DisplayName = "Approval Workgroup ID")]
        public virtual int? WorkgroupID { get; set; }
        public abstract class workgroupID : BqlInt.Field<workgroupID> { }
        #endregion
    }
}

The PXPrimaryGraphAttribute is crucial here—this attribute helps the EP503010 Approvals screen determine the appropriate graph to redirect the user to. Without it, the system won’t be able to navigate to the screen where the details can be edited. It’s also recommended to use PXCacheNameAttribute to control the display name shown in the EP503010 Approvals screen.

  • It’s recommended to use a user-friendly name instead of relying on nameof.

3. Add Approval Views to Your Graph

Here’s an example from APInvoiceEntry. You’ll need to modify it to work with your DACs.

public PXSelect<APSetupApproval,
Where<APSetupApproval.docType, Equal<Current<APInvoice.docType>>,
    Or<Where<Current<APInvoice.docType>, Equal<APDocType.prepayment>,
        And<APSetupApproval.docType, Equal<APDocType.prepaymentRequest>>>>>> SetupApproval;
        
[PXViewName(PX.Objects.EP.Messages.Approval)]
public EPApprovalAutomationWithoutHoldDefaulting<APInvoice, APInvoice.approved, APInvoice.rejected, APInvoice.hold, APSetupApproval> Approval;

4. Modify Approval Logic

This section explains how to modify the existing APInvoiceEntry example to fit your DACs and set up the necessary approval views.

#region EP Approval Defaulting
[PXMergeAttributes(Method = MergeMethod.Merge)]
[PXDefault(typeof(APInvoice.docDate), PersistingCheck = PXPersistingCheck.Nothing)]
protected virtual void _(Events.CacheAttached<EPApproval.docDate> e) { }

[PXMergeAttributes(Method = MergeMethod.Merge)]
[PXDefault(typeof(APInvoice.vendorID), PersistingCheck = PXPersistingCheck.Nothing)]
protected virtual void _(Events.CacheAttached<EPApproval.bAccountID> e) { }

[PXMergeAttributes(Method = MergeMethod.Merge)]
[PXDefault(typeof(APInvoice.employeeID), PersistingCheck = PXPersistingCheck.Nothing)]
protected virtual void _(Events.CacheAttached<EPApproval.documentOwnerID> e) { }

[PXMergeAttributes(Method = MergeMethod.Merge)]
[PXDefault(typeof(APInvoice.docDesc), PersistingCheck = PXPersistingCheck.Nothing)]
protected virtual void _(Events.CacheAttached<EPApproval.descr> e) { }

[PXMergeAttributes(Method = MergeMethod.Merge)]
[CurrencyInfo(typeof(APInvoice.curyInfoID))]
protected virtual void _(Events.CacheAttached<EPApproval.curyInfoID> e) { }

[PXMergeAttributes(Method = MergeMethod.Merge)]
[PXDefault(typeof(APInvoice.curyOrigDocAmt), PersistingCheck = PXPersistingCheck.Nothing)]
protected virtual void _(Events.CacheAttached<EPApproval.curyTotalAmount> e) { }

[PXMergeAttributes(Method = MergeMethod.Merge)]
[PXDefault(typeof(APInvoice.origDocAmt), PersistingCheck = PXPersistingCheck.Nothing)]
protected virtual void _(Events.CacheAttached<EPApproval.totalAmount> e) { }

public static readonly Dictionary<string, string> APDocTypeDict = new APDocType.ListAttribute().ValueLabelDic;
protected virtual void _(Events.FieldDefaulting<EPApproval.sourceItemType> e)
{
    if (Document.Current != null)
    {
        e.NewValue = APDocTypeDict[Document.Current.DocType];

        e.Cancel = true;
    }
}
#endregion EP Approval Defaulting

If You Need to Change Approval Logic

Adding CacheAttached events allows you to override default values that are populated into the EPApproval row when a new approval assignment is created.

5. Create Actions for the Approval Flow

Actions can be created with conditions that determine when they are visible or enabled within the workflow. Below is an example of defining a new action in the workflow and how to use existing actions.

 

// this is an example of how you can define a new action in a workflow
_unhold = context.ActionDefinitions
    .CreateNew("Remove Hold", a => a
    .WithCategory(_processing)
    .IsHiddenWhen(_conditions.IsDraft)
    .WithFieldAssignments(fas =>
    {
        fas.Add<APInvoice.hold>(f => f.SetFromValue(false));
    }));

// if you need to use existing action
_unhold = context.ActionDefinitions
    .CreateExisting(g => g.releaseFromHold, c => c
    .WithCategory(_processing)
    .WithFieldAssignments(fas =>
    {
        fas.Add<APInvoice.hold>(f =>f.SetFromValue(false));
    }));
  • – Complete configuration with an explanation will be presented below.  

If you want to follow the conventional approach, you can also create buttons directly in your graph. This example shows how to add buttons for approving, rejecting, holding, or removing hold status on an invoice. 

#region Actions
public PXAction<APInvoice> approve;
[PXButton(CommitChanges = true), PXUIField(DisplayName = "Approve")]
protected virtual IEnumerable Approve(PXAdapter adapter) => adapter.Get();

public PXAction<APInvoice> reject;
[PXButton(CommitChanges = true), PXUIField(DisplayName = "Reject")]
protected virtual IEnumerable Reject(PXAdapter adapter) => adapter.Get();

public PXAction<APInvoice> putOnHold;
[PXButton(CommitChanges = true), PXUIField(DisplayName = "Hold")]
protected virtual IEnumerable PutOnHold(PXAdapter adapter) => adapter.Get();

public PXAction<APInvoice> releaseFromHold;
[PXButton(CommitChanges = true), PXUIField(DisplayName = "Remove Hold")]
protected virtual IEnumerable ReleaseFromHold(PXAdapter adapter) => adapter.Get();
#endregion Actions

If you’re using a workflow, you don’t need to include your logic here. Otherwise, you’ll need to manually handle the state changes to trigger the correct events.

6. Add Approval Logic

Approval handling is mainly located within the EPApprovalAutomation or EPApprovalAutomationWithoutHoldDefaulting which you added in the previous step. Depending on your specific approval flow, you may need to add customizations for your setup.  

For example, in a custom Bills Approval screen, you might define the following:

public PXWorkflowEventHandler<APInvoice> OnUpdateStatus;
public PXInitializeState<APInvoice> initializeState;

7. Add Workflow Configuration

For those using workflows, Acumatica typically creates a separate extension for workflow configuration, such as APInvoiceEntry_ApprovalWorkflow. However, this guide provides an alternative by incorporating the workflow configuration directly within your graph, allowing for more streamlined management. Here’s an example of how to configure the workflow within your graph:

  • – The code below is mostly an excerpt of the workflow from APInvoiceEntry_ApprovalWorkflow, changed only to work within 1 graph class and 1 service class.  
  • – If you want/need to work inside an extension, better to use APInvoiceEntry_ApprovalWorkflow as an example instead.  
  • – Add this to the graph:  
[PXWorkflowDependsOnType(typeof(APSetupApproval))]
public override void Configure(PXScreenConfiguration config)
{
    var context = config.GetScreenConfigurationContext<ApprovableRecordMaint, APInvoice>();
    var asm = new ApprovalStateMachine(context);

    context.AddScreenConfigurationFor(screen => screen
        .StateIdentifierIs<APInvoice.status>()
        .AddDefaultFlow(flow => flow
            .WithFlowStates(fss => asm.DefineStates(fss))
            .WithTransitions(transitions => asm.DefineTransitions(transitions)))
            .WithActions(actions => asm.DefineActions(actions))
            .WithHandlers(handlers => asm.DefineHandlers(handlers))
            .WithCategories(categories => asm.DefineCategories(categories)));
}
  • – Then create service:  
using PX.Data.WorkflowAPI;
using PX.Objects.AP;

namespace Sprinterra.ApprovalHowTo
{
    using static BoundedTo<ApprovableRecordMaint, APInvoice>;

    public class ApprovalStateMachine
    {
        private const string _initial = "_";
        private readonly ActionCategory.IConfigured _approval;
        private readonly ActionCategory.IConfigured _processing;
        private readonly ActionDefinition.IConfigured _approve;
        private readonly ActionDefinition.IConfigured _reject;
        private readonly ActionDefinition.IConfigured _reassign;
        private readonly ActionDefinition.IConfigured _hold;
        private readonly ActionDefinition.IConfigured _unhold;
        private readonly Conditions _conditions;

        public ApprovalStateMachine(WorkflowContext<ApprovableRecordMaint, APInvoice> context)
        {
            _conditions = context.Conditions.GetPack<Conditions>();
            _approval = context.Categories.CreateNew("Approval", category => category.DisplayName("Approval"));
            _processing = context.Categories.CreateNew("Processing", category => category.DisplayName("Processing"));

            _unhold = context.ActionDefinitions
                .CreateExisting(g => g.releaseFromHold, c => c
                .WithCategory(_processing)
                .WithFieldAssignments(fas =>
                {
                    fas.Add<APInvoice.hold>(f => f.SetFromValue(false));
                }));

            _approve = context.ActionDefinitions
                .CreateExisting(g => g.approve, a => a
                .WithCategory(_approval)
                .PlaceAfter(_unhold)
                .IsHiddenWhen(_conditions.IsApprovalDisabled)
                .WithFieldAssignments(fa => fa.Add<APInvoice.approved>(e => e.SetFromValue(true))));

            _reject = context.ActionDefinitions
                .CreateExisting(g => g.reject, a => a
                .WithCategory(_approval, _approve)
                .PlaceAfter(_approve)
                .IsHiddenWhen(_conditions.IsApprovalDisabled)
                .WithFieldAssignments(fa => fa.Add<APInvoice.rejected>(e => e.SetFromValue(true))));

            _reassign = context.ActionDefinitions
                .CreateExisting(nameof(ApprovableRecordMaint.Approval.ReassignApproval), a => a
                .WithCategory(_approval)
                .PlaceAfter(_reject)
                .IsHiddenWhen(_conditions.IsApprovalDisabled));

            _hold = context.ActionDefinitions
                .CreateExisting(g => g.putOnHold, c => c
                .WithCategory(_processing)
                .WithFieldAssignments(fa =>
                {
                    fa.Add<APInvoice.hold>(f => f.SetFromValue(true));
                    fa.Add<APInvoice.approved>(e => e.SetFromValue(false));
                    fa.Add<APInvoice.rejected>(e => e.SetFromValue(false));
                }));
        }

        public void DefineStates(BaseFlowStep.IContainerFillerStates flow)
        {
            flow.Add(_initial, flowState => flowState.IsInitial(g => g.initializeState));

            flow.AddSequence<APDocStatus.HoldToBalance>(seq =>
            {
                return seq
                .WithStates(sss =>
                {
                    sss.Add<APDocStatus.hold>(fs =>
                    {
                        return fs
                        .IsSkippedWhen(_conditions.IsNotOnHold)
                        .WithActions(actions => 
                        {
                            actions.Add(_unhold, a => a.IsDuplicatedInToolbar().WithConnotation(ActionConnotation.Success));
                        });
                    });

                    sss.Add<APDocStatus.balanced>(fs =>
                    {
                        return fs
                        .PlaceAfter<APDocStatus.pendingApproval>()
                        .WithActions(actions =>
                        {
                            actions.Add(_hold);
                        })
                        .WithEventHandlers(handlers =>
                        {
                            handlers.Add(g => g.OnUpdateStatus);
                        });
                    });

                    sss.Add<APDocStatus.pendingApproval>(fs =>
                    {
                        return fs
                        .IsInitial()
                        .WithActions(actions =>
                        {
                            actions.Add(_approve, a => a.IsDuplicatedInToolbar());
                            actions.Add(_reject, a => a.IsDuplicatedInToolbar());
                        });
                    });
                });
            });

            flow.Add<APDocStatus.rejected>(fs =>
                fs.WithActions(actions =>
                {
                    actions.Add(_hold, a => a.IsDuplicatedInToolbar());
                })
            );
        }

        public void DefineTransitions(Transition.IContainerFillerTransitions flow)
        {
            flow.AddGroupFrom(_initial, ts =>
            {
                ts.Add(t => t
                    .To<APDocStatus.HoldToBalance>()
                    .IsTriggeredOn(g => g.initializeState));
            });


            flow.AddGroupFrom<APDocStatus.HoldToBalance>(ts =>
            {
                ts.Add(t => t
                    .To<APDocStatus.HoldToBalance>()
                    .IsTriggeredOn(g => g.OnUpdateStatus));
            });

            flow.AddGroupFrom<APDocStatus.pendingApproval>(ts =>
            {
                ts.Add(t => t
                    .To<APDocStatus.HoldToBalance>()
                    .IsTriggeredOn(g => g.OnUpdateStatus));

                ts.Add(t => t
                    .ToNext()
                    .IsTriggeredOn(_approve)
                    .When(_conditions.IsApproved));
                ts.Add(t => t
                    .To<APDocStatus.rejected>()
                    .IsTriggeredOn(_reject)
                    .When(_conditions.IsRejected));
            });

            flow.AddGroupFrom<APDocStatus.rejected>(ts =>
            {
                ts.Add(t => t
                    .To<APDocStatus.hold>()
                    .IsTriggeredOn(_hold)
                );
            });

            flow.AddGroupFrom<APDocStatus.balanced>(ts =>
            {
                ts.Add(t => t
                    .To<APDocStatus.pendingApproval>()
                    .IsTriggeredOn(_hold)
                );
            });
        }

        public void DefineHandlers(WorkflowEventHandlerDefinition.IContainerFillerHandlers flow)
        {
            flow.Add(handler => 
                handler
                .WithTargetOf<APInvoice>()
                .OfFieldUpdated<APInvoice.hold>()
                .Is(g => g.OnUpdateStatus)
                .UsesTargetAsPrimaryEntity());
        }

        public void DefineActions(ActionDefinition.IContainerFillerActions flow)
        {
            flow.Add(_approve);
            flow.Add(_reject);
            flow.Add(_reassign);
            flow.Add(_unhold);
            flow.Add(_hold);
        }

        public void DefineCategories(ActionCategory.IContainerFillerCategories flow)
        {
            flow.Add(_approval);
        }
    }
}
  • – Add Conditions class:  
using PX.Data;
using PX.Data.WorkflowAPI;
using PX.Objects.AP;

namespace Sprinterra.ApprovalHowTo
{
    using static BoundedTo<ApprovableRecordMaint, APInvoice>;

    public class Conditions : Condition.Pack
    {
        public Condition IsApproved => GetOrCreate(b => b.FromBql<APRegister.approved.IsEqual<True>>());
        public Condition IsRejected => GetOrCreate(b => b.FromBql<APRegister.rejected.IsEqual<True>>());
        public Condition IsNotOnHold => GetOrCreate(c => c.FromBql<APRegister.hold.IsEqual<False>>());
        public Condition IsApprovalDisabled => GetOrCreate(b => b.FromBqlType(APApprovalSettings.IsApprovalDisabled<
            APInvoice.docType, 
            APDocType, 
            Where<APInvoice.status.IsNotIn<APDocStatus.pendingApproval, APDocStatus.rejected>>>()));
    }
}
  • If you take a look at the APInvoiceEntry_Workflow or APInvoiceEntry_ApprovalWorkflow, Acumatica prefers to store Conditions class declarations in the same file as workflow.  
    In my experience, holding several classes in 1 file always causes issues when other developers need to work with the code – it takes more time to find the 2nd class.  
    Because of that I tend to separate classes to have 1 class per file. Exception – when I create a nested class (in this case, I often make it private and intend to use it only within that file).  

Afterword

By following these steps, you will have established the core logic of your approval flow. From here, you can further customize it based on your specific requirements.

Hope this helps you on your journey of customizing Acumatica!

Happy coding!

Would you like a free ball-park estimate for your project?

We can start working on it right now!

You can also contact us directly

To discover how our solutions can help you get to the finish line faster

email: [email protected]

Set-up a meeting at your convenience

Use our Proposal Generator to create your custom Proposal for Healthcare or FinTech

Subscribe To Our Newsletter

Get the latest insights on exponential technologies delivered straight to you