Workflow notifications and approvals through Power Automate — Dynamics 365 F&O

Muhammed Uğur Yıldız
6 min readDec 18, 2023

--

Let’s say you want to inform users when there’s a record is sent to workflow and it needs to be approved or rejected and you don’t want users to login to system and check their waiting approvals. So here we can use Power Automate to send users notifications or even approvals and those things can be send to Teams and Outlook. Users can approve or reject workflow requests from their mobile phones too. I’ll try to explain how to achieve this.

First of all we need Power Automate license because Dynamics 365 F&O connector is premium only. Also the user you use to log into Power Automate portal should have System Administrator role on Dynamics 365 F&O.

We will be using Business Events on the Dynamics 365 F&O side.

As it’s shown above Workflow workitem business events have several fields that can be passed from Dynamics to another endpoint. Our endpoint will be Power Automate.

To add our custom fields to this workflow workitem list we need to create extension to “BusinessEventsWorkflowWorkItemDataContract” class.

[ExtensionOf(classStr(BusinessEventsWorkflowWorkItemDataContract))]
final class BusinessEventsWorkflowWorkItemDataContract_DAX_Extension
{
private Array attachedDocuments;
private Array fileNames;
private Array parametricData;
private str parametricSubject;
private str parametricIsActive;


[DataMember("attachedDocuments"), BusinessEventsDataMember("AttachmentStream")]
public Array parmAttachments(Array _attachedDocuments = attachedDocuments)
{
attachedDocuments = _attachedDocuments;
return attachedDocuments;
}

[DataMember("fileNames"), BusinessEventsDataMember("AttachmentNames")]
public Array parmFileNames(Array _fileNames = fileNames)
{
fileNames = _fileNames;
return fileNames;
}

[DataMember("parametricData"), BusinessEventsDataMember("DAX Data")]
public Array parmParametricData(Array _parametricData = parametricData)
{
parametricData = _parametricData;
return parametricData;
}

//Just before SerializedWorkflowDocument returns data we parm our custom data to contract.

public Array parmSerializedWorkflowDocument(Array _serializedWorkflowDocument)
{
Array tempSerialized = next parmSerializedWorkflowDocument(_serializedWorkflowDocument);

DAXMobileWFHelper helper = DAXMobileWFHelper::construct(this.parmWorkflowCorrelationId());
helper.run();

this.parmAttachments(helper.getAttachmentStreamArray());
this.parmFileNames(helper.getAttachmentNamesArray());
this.parmParametricData(helper.getRecordDataArray());

return tempSerialized;
}

}

To build your data you can use your own helper class. I’ll provide an example below.

DAXMobileWFHelper:

class DAXMobileWFHelper 
{
//"g" indicates global variable

str gWorkflowCorrelationId;
DataAreaId gCompanyId;
RecId gRecId;
TableId gTableId;

Array gAttachmentStream;
Array gAttachmentNames;
Array gRecordData;


public static DAXMobileWFHelper construct(str _workflowCorrelationId)
{
WorkflowTrackingStatusTable trackingStatus;

select firstonly trackingStatus
where trackingStatus.CorrelationId == str2Guid(_workflowCorrelationId);

switch(trackingStatus.ContextTableId)
{
case tableNum(PurchTable):
return new DAXMobileWFHelper_Purch(_workflowCorrelationId,trackingStatus.ContextCompanyId,trackingStatus.ContextRecId,trackingStatus.ContextTableId);
break;
case tableNum(SalesTable):
return new DAXMobileWFHelper_Sales(_workflowCorrelationId,trackingStatus.ContextCompanyId,trackingStatus.ContextRecId,trackingStatus.ContextTableId);
break;
default:
return new DAXMobileWFHelper(_workflowCorrelationId,trackingStatus.ContextCompanyId,trackingStatus.ContextRecId,trackingStatus.ContextTableId);
break;
}
}

//If table has save data per company as no then tableId will be 0.
public void new(str _workflowCorrelationId,DataAreaId _companyId,RecId _recordId,TableId _tableId)
{
gWorkflowCorrelationId = _workflowCorrelationId;
gCompanyId = _companyId;
gRecId = _recordId;
gTableId = _tableId;
}

public void run()
{
if(this.validate())
{
this.setAttachmentStreamArray();
this.setAttachmentNamesArray();
this.setRecordDataArray();
}
}

//Default behavior, you can override in your extends class.
protected void setAttachmentStreamArray()
{
System.IO.Stream stream;
System.Byte[] bytes;
System.IO.MemoryStream memoryStream = new System.IO.MemoryStream();

gAttachmentStream = new Array(Types::String);
Counter arrayCount = 1;

str attachmentBase64;
DocuRef docuRef;

while select crosscompany docuRef
index hint RefIdx
order by Name asc
where docuRef.RefCompanyId == gCompanyId
&& docuRef.RefTableId == gTableId
&& docuRef.RefRecId == gRecId

{

stream = DocumentManagement::getAttachmentStream(docuRef);
stream.CopyTo(memoryStream);
bytes = memoryStream.ToArray();

attachmentBase64 = System.Convert::ToBase64String(bytes);
gAttachmentStream.value(arrayCount,attachmentBase64);
arrayCount++;

memoryStream.SetLength(0);
}

memoryStream.Close();
}

protected void setAttachmentNamesArray()
{
gAttachmentNames = new Array(Types::String);
Counter arrayCount = 1;
DocuRef docuRef;
str fileName;


while select crosscompany docuRef
index hint RefIdx
order by Name asc
where docuRef.RefCompanyId == gCompanyId
&& docuRef.RefTableId == gTableId
&& docuRef.RefRecId == gRecId

{
fileName = docuRef.Name + "." + docuRef.docuValue().FileType;
gAttachmentNames.value(arrayCount,fileName);
arrayCount++;
}

}


protected void setRecordDataArray()
{
gRecordData = new Array(Types::String);
}

//You can check null array here, since we don't want to break workflow process

public Array getAttachmentStreamArray()
{
return gAttachmentStream;
}

public Array getAttachmentNamesArray()
{
return gAttachmentNames;
}

public Array getRecordDataArray()
{
return gRecordData;
}
}

DAXMobileWFHelper_Purch:

final class DAXMobileWFHelper_Purch extends DAXMobileWFHelper
{

protected void setRecordDataArray()
{
PurchTable purchTable = PurchTable::findRecId(gRecId);
PurchLine purchLine;
Counter arrayCount = 1;
int lineNum;
str fieldInfo;

gRecordData = new Array(Types::String);

fieldInfo = strFmt("%1: %2", fieldId2PName(tableNum(PurchTable), fieldNum(PurchTable, CurrencyCode)), purchTable.CurrencyCode);
gRecordData.value(arrayCount, fieldInfo);
arrayCount++;

fieldInfo = strFmt("%1: %2", fieldId2PName(tableNum(PurchTable), fieldNum(PurchTable, PaymMode)), purchTable.PaymMode);
gRecordData.value(arrayCount, fieldInfo);
arrayCount++;

fieldInfo = strFmt("%1: %2", fieldId2PName(tableNum(PurchTable), fieldNum(PurchTable, DlvMode)), purchTable.DlvMode);
gRecordData.value(arrayCount, fieldInfo);
arrayCount++;

fieldInfo = strFmt("%1: %2", fieldId2PName(tableNum(PurchTable), fieldNum(PurchTable, DlvTerm)), purchTable.DlvTerm);
gRecordData.value(arrayCount, fieldInfo);
arrayCount++;

fieldInfo = strFmt("%1: %2", fieldId2PName(tableNum(PurchTable), fieldNum(PurchTable, PortIssue)), purchTable.PortIssue);
gRecordData.value(arrayCount, fieldInfo);
arrayCount++;

fieldInfo = strFmt("%1: %2", fieldId2PName(tableNum(PurchTable), fieldNum(PurchTable, PortReceipt)), purchTable.PortReceipt);
gRecordData.value(arrayCount, fieldInfo);
arrayCount++;

}

}

Now let’s build Power Automate part. First create a new flow, you can also use Complete Dynamics 365 for Finance and Operations workflow work items (PU29) template published by Microsoft.

Search for “When a Business Event Occurs” for Fin&Ops Apps

Provide required parameters, instance can be your development environment and business event should be chosen as workflow workitem approval.

Body : @{triggerBody()}

Use parse JSON item to access business event data. Download scheme from Dynamics side and supply it to generate from sample.

From value : @body(‘Data_From_Workflow_Item’)?[‘ListOfOutcomes’]

Value : @{body(‘Filter_Outcomes’)}

I wanted to use only approve and reject so I removed delegate option.

fileNames array value : @{body(‘Data_From_Workflow_Item’)?[‘fileNames’]}

attachedDocuments array value : @{body(‘Data_From_Workflow_Item’)?[‘attachedDocuments’]}

We need to declare some variables as shown above.

From value : @{body(‘Data_From_Workflow_Item’)?[‘parametricData’]}

Join with : @{decodeUriComponent(‘%0A’)}

We use JSON join to break lines //this one is equals to ‘\n’

Check Array function : length(body(‘Data_From_Workflow_Item’)?[‘attachedDocuments’])

Set array length function : length(body(‘Data_From_Workflow_Item’)?[‘attachedDocuments’])

isDocumentExists Value : @variables(‘documentExists’)

Do until : @variables(‘loop’) is equal to @ variables(‘arrayLength’)

Validate Workitem without Document : @{body(‘Data_From_Workflow_Item’)?[‘WorkflowWorkItemInstanceId’]}

I’ll first provide document section and then come back to non document section.

Append to array variable :

{
“Name”: @{variables(‘fileNames’)[variables(‘loop’)]},
“Content”: @{variables(‘attachedDocuments’)[variables(‘loop’)]}
}

WorkflowWorkItemInstanceId : @{body(‘Data_From_Workflow_Item’)?[‘WorkflowWorkItemInstanceId’]}

Condition value : @body(‘Validate_Workitem’)?[‘OutputParameters’]?[‘value’] is equal to True

Response options : @variables(‘filteredOutcomes’)

Assigned to : @{body(‘Data_From_Workflow_Item’)?[‘WorkflowUserEmail’]}

Details : @{body(‘BreakToNewLine’)}

Item link : @{body(‘Data_From_Workflow_Item’)?[‘LinkToWeb’]}

Item link description : @{body(‘Data_From_Workflow_Item’)?[‘WorkflowDocument’]}

Attachments : @variables(‘documentArray’)

Responses : @{body(‘Start_and_wait_for_an_approval_(V2)’)?[‘responses’]}

WorkflowWorkItemInstanceId : @{body(‘Data_From_Workflow_Item’)?[‘WorkflowWorkItemInstanceId’]}

Outcome : @{body(‘Start_and_wait_for_an_approval_(V2)’)?[‘outcome’]}

Comment : @{items(‘Apply_to_each’)?[‘comments’]}

TargetUser : @{body(‘Data_From_Workflow_Item’)?[‘WorkitemOwner’]}

RunAsUser : @{body(‘Data_From_Workflow_Item’)?[‘WorkitemOwner’]}

You can use the same config for non document part without supplying document array to attachments.

One disadvantage is that if you approve a workflow request from Dynamics 365 F&O side, it doesn’t cancel specific flow run on the Power Automate side but no worries it can also be implemented by using Data Entities and entity methods. I’ll share my implemention with you when I have time to complete it :)

--

--

Responses (1)