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 ofnameof
. Â
Â
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 theAPInvoiceEntry_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
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!