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
orAPInvoiceEntry_ApprovalWorkflow
, Acumatica prefers to storeConditions
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