Skip to main content

Hello everyone.

Today I want to share with you a step-by-step guide on how can you set up an Approval flow for your custom DACs or a custom Approval screen for standard DACs (which already support Approval flow).


If you are using standard DACs - you can skip the first 2 chapters and go to Add approval views to your graph chapter right away.  

 

Create Assignment Map

To make your setup DAC able to configure the approval process, you add the IAssignedMap interface and implement the required fields:  

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

namespace Sprinterra.ApprovalHowTo
{
rPXCacheName("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
}
}

 

Create approvable DAC

For the record that should be approvable, you add the IAssign interface, which will require you to define the OwnerID and WorkgroupID fields. But for the EPApprovalAutomation view, you will also need the Approved, Rejected and Hold fields.  
See 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
{
TSerializable]
aPXCacheName(nameof(ApprovableRecord))]
RPXPrimaryGraph(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>
yPXDBBool]
BPXUIField(DisplayName = "Approve", Visibility = PXUIVisibility.Visible)]
bPXDefault(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>
yPXDBBool]
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>
yPXDBBool()]
oPXUIField(DisplayName = "Hold", Visibility = PXUIVisibility.Visible)]
bPXDefault(true)]
public virtual bool? Hold { get; set; }
public abstract class hold : BqlBool.Field<hold> { }
#endregion

#region OwnerID
nOwner(typeof(workgroupID))]
IPXMassUpdatableField]
iPXMassMergableField]
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>
yPXInt]
XPXSelector(typeof(Search<EPCompanyTree.workGroupID>), SubstituteKey = typeof(EPCompanyTree.description))]
oPXUIField(DisplayName = "Approval Workgroup ID")]
public virtual int? WorkgroupID { get; set; }
public abstract class workgroupID : BqlInt.Field<workgroupID> { }
#endregion
}
}
  • PXPrimaryGraphAttribute is essential - the EP503010 Approvals screen searches for this attribute to determine the graph to redirect to. Without this attribute, it won't be able to send a user to a screen to edit details.  
  • PXCacheNameAttribute allows you to control the type name shown at the EP503010 Approvals screen. It’s recommended to use a user-friendly name instead of nameof.  

 

Add approval views to your graph

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;

Example from APInvoiceEntry. Change it to use your DACs.  

 

If you need to change approval filling logic

Add these CacheAttached events:  

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

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

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

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

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

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

EPXMergeAttributes(Method = MergeMethod.Merge)]
ePXDefault(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

The idea is to override what values will be placed into the EPApproval row when new a approval assignment is created.  

 

Create Actions for approval flow

You can create actions and make conditions when they are visible and/or enabled from the workflow. To create a new action, you need to define it in the scope Configure method in the graph (this code is shown below). In the example provided in this article we will use actions created in a standard way, but you can replace them like so:  

// 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 completely follow this article or prefer a more conventional approach, create these buttons in your graph:  

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

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

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

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


If you use workflow, you don't need to put your logic here.  
Otherwise, you need to manually handle changing flags to trigger the right events.  

 

Add approval logic

Main approval handling is located inside EPApprovalAutomation/EPApprovalAutomationWithoutHoldDefaulting, which we added at Add approval views to your graph step.  
Depending on your flow, you might also need some specific things for your particular setup.  
I will show what was necessary to setup Bills Approval on a custom screen:

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

 

Add workflow configuration

If you don't use workflow - you can skip this step.  
Acumatica always creates a separate extension for the workflow. In the case of APInvoiceEntry and APInvoice (Bill) approval - they create 1 extension for the base workflow and another for the Approval workflow.  
I'm not a fan of creating extensions when you have access to the graph, so I created my service, that can provide configuration parts to the main 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

When you complete those steps, you should have the main logic of your Approval flow established. Now you can fill it depending on your needs.

Hope this helps on your journey of customizing Acumatica.

Happy coding!

Thank you for sharing this tip with the community @andriitkachenko!


Reply