Monday, 22 July 2024

Microsoft Dynamics 365 Finance - RunBaseBatch

RunBaseBatch


This is an article that I wrote ages ago and was first published on Axaptapedia which of course does not exist anymore. I discovered my original article in my archive and thought it was worth while adding it as a blog since most of it is still relevant in Dynamics 365 Finance and Operations. I made some minor edits to the article and removed a few things that have been deprecated in Dynamice 365 Finance and Operations. The original article was written for Axapta version 4.0.

Okay, enough of that. Let's start!

Introduction

The RunBaseBatch class extends the RunBase class and allows one to create classes (jobs) that can be added to the batch queue. Once the job has been added to the queue, it will be executed by the batch server. This concept is known as batch processing.

Input data parameters

Usually a class contains logic only and does not have a graphical user interface to interact with. Because the RunBaseBatch class inherits from the RunBase class, it uses the dialog framework to interactively prompt users for input data (parameters). The parameters are class member variables and are added to the dialog box. The type of control that is used on the dialog depends on the extended data type that is used when adding the controls to the dialog. Certain properties of the controls can be modified on the Extended Data Type itself. E.g. with Base enums it is possible to set the style property to Auto, Combo box or Radio button.

If a control is added using a variable of type boolean, it will be created on the dialog as a checkbox, enum as a combo box, string as a text edit control. Each dialog control has a corresponding class variable which is used to set and get the values from the dialog controls and to persist the values for use later when the job is executed by the batch server. Usually the controls are added to the dialog in code. Because the controls are added during runtime, overriding the methods of the controls requires a different approach compared to a standard form. Instead of adding the controls in code, it is possible to use an existing dialog that has been created in the AOT. This gives the same level of control over the controls as a standard form.

The parameters are used to control the logic of the job. This improves the flexibility and re-usability of jobs. In other words, a job can behave differently depending on the selected parameters. For example, a job that cleans up a log table might accept a days parameter allowing the user to specify how "old" the records should be to delete. If the user enters 30, the job deletes all records that are older than 30 days.

Persistence

The parameters are class variables and their values are persisted (packed) in the database by the SysLastValue framework. The parameters are packed when the job is added to the batch queue and are retrieved (unpacked) from the database by the SysLastValue framework when the job is executed by the batch server. The class variables are re-instantiated to their original values in the unpack method. In order for a class to persist the values of class variables, the variables must added to a local macro list and the class must override and implement the pack and unpack methods correctly. In fact, because the RunbaseBatch class implements the SysPackable interface, all child classes of RunBaseBatch must override the pack and unpack methods.

The pack method is called by the RunBaseBatch framework when a job is placed in the batch queue by the user. The unpack method is called before the job is executed by the batch server. When the job is executed by the batch server, the new, unpack and run methods are the only methods called by the RunBaseBatch framework. The dialog is not shown because the job is executed by another computer, or server, and there is no user interaction expected at this point. The run method should be overridden and the core code of the class should be placed in the run method.

Query

The RunBaseBatch class supports the use of a query to make a selection on underlying data. If the class uses a query, and the queryRun method is overridden, the RunBase framework automatically displays the query range and their values on the dialog. By clicking on the "Select" button, the user can modify the query by setting or removing query values or by adding new ranges. It is possible to extend the query by adding relations (joins) to related tables and lastly by changing the sort order. If the query is going to be used re-used, it is recommended to create the query in the AOT instead of in code.

Operation progress

If a job performs an operation that takes a considerable length of time, it is recommended to display a progress bar. The RunBase framework uses the Operation Progress framework to display the progress of an operation. The RunBaseBatch class inherits this functionality from the RunBase class. In order to display the current progress, code must be added to the run method of the class.

Making the class runnable

By default a class is not "executable" i.e. it cannot be opened from the main menu or from a form with the use a menu item. In order to make a class "executable", it must have a static main method (entry point) with a single args class parameter. When a class is executed, the kernel calls the static main method of the class and passes it an Args object. The main method is called interactively and is not called by the batch server.

The Args object contains properties that can be used by the class. E.g. if the Args object contains a reference to an active buffer, the main method can use this buffer to construct specialised child classes of the parent class depending on the values of the fields in the buffer. This is known as the constructor based object-oriented design pattern and is used extensively throughout the application. In addition to having an active buffer, the Args object might contain a reference to the class's caller which can be a form or an object. The Args class provides a convenient mechanism to communicate between objects and is widely used in the application. With the use of a menu item, the class (job) can be accessed from the main menu or from a form.

Considerations


New method

Because classes that inherit from RunBaseBatch can be executed in a batch journal, the new method of should not have any arguments. The reason for this is that object variable types might cause stack trace errors when the system enumerates all child classes of RunBaseBatch during the process of setting up batch journals.

Version number

Class variables are added to a local macro as a "list" of variables so that their values can be persisted by the SysLastValue framework. The variable list has a corresponding version number in order to keep track of the different versions of lists that might exist. The version number usually starts at one and is increased every time that the number of variables in the list is changed. The unpack method should be modified accordingly to allow the values to the variables to be assigned correctly and to prevent stack trace errors from occurring when the size of the persisted variables does not match the size of the local macro list.

Methods


new

Creates a new instance of the class. Called when executed in batch.

pack

Class variables are persisted by using a local macro. Called when the dialog box is closed by selecting "OK".

unPack

The class variables in the local macro are re-instantiated. The unpack method is called before the run. Called when executed in batch.

dialog

Controls are added to the dialog box and the values of the controls are set by the  the values of their corresponding class variables.

getFromDialog

The values of the class variables are assigned by the values of the controls that have been added to the dialog box.

validate

The input parameters can be validated. If the input are invalid, an info log can be displayed so the user can take action.

run

The central method of the class.. Called when executed in batch.

initParmDefault

Called when the unPack method returns false i.e. no usage data found. Class variables that are in the macro list should be initialised in the initParmDefault method.

queryRun

If the class uses a query, this method should return a queryRun object.

canGoBatchJournal

Indicates if the class(job) can be included in a batch journal. Default is false.

canGoBatch

Indicates if the class can be executed in batch. The framework adds the "Batch" tab page to the dialog if this method returns true. Default is true.

runsImpersonated

Indicates if the job should be run using the submitting user’s account or the batch user’s account. The default is to use batch user’s account. If true is returned, the batch server will execute the job using the X++ "runAs" statement. Version 4 and later. Default is false.

description (static)

Returns the description of the (base) class and will be displayed as the description in the batch list and determines the title of the dialog

caption

The description of the class can be overridden by returning the description of the child class. It will be displayed as the description in the batch list and determines the title of the dialog.

Creating a basic batch job step by step


From the AOT create a class and extend it from RunBaseBatch.

public class AV_RunbaseBatchDemo extends RunBaseBatch
{
}

In the class declaration add the following.

NoYes           displayMessage;
Description     message;

DialogField     fieldDisplayMessage;
DialogField     fieldMessage;
        
#define.CurrentVersion(1)

#localmacro.CurrentList
    displayMessage,
    message
#endmacro

Override the pack method.

public container pack()
{
    return [#CurrentVersion, #CurrentList];
}

Override the unpack method.

public boolean unpack(container _packedClass)
{
    Integer version = RunBase::getVersion(_packedClass);

    switch (version)
    {
        case #CurrentVersion:
            [version, #CurrentList] = _packedClass;
            break;

        default:
            return false;
    }

    return true;
}

Create the description method, this will be displayed in the batch queue and it will the caption of the dialog.

public static ClassDescription description()
{
    return "Runbase batch demo";
}

Create the construct method.

public static AV_RunbaseBatchDemo construct()
{
    return new AV_RunbaseBatchDemo();
}

Override the initParmDefault method.

public void initParmDefault()
{
    super();
    displayMessage  = NoYes::Yes;
    message         = "Hello World!";
}

Override the dialog method.

protected Object dialog()
{
    DialogRunbase dlg;

    dlg = super();
    fieldDisplayMessage = dlg.addFieldValue(typeId(NoYes),
        displayMessage,
        "Display message",
        "Indicates whether to display a message or not.");
    fieldMessage = dlg.addFieldValue(typeId(Description),
        message,
        "Message",
        "The message to display");
    return dlg;
}

Override the getFromDialog method.

public boolean getFromDialog()
{
    boolean ret;

    ret             = super();
    displayMessage  = fieldDisplayMessage.value();
    message         = fieldMessage.value();

    return ret;
}

Override the run method.

public void run()
{
    if (displayMessage)
    {
        info(message);
    }
}

Create the static main method.

public static void main(Args )
{
    AV_RunbaseBatchDemo rb = AV_RunbaseBatchDemo::construct();

    if (rb.prompt())
    {
        rb.run();
    }
}

Tuesday, 16 July 2024

Microsoft Dynamics 365 Finance - How to change the default model in Visual Studio

When creating new projects in Visual Studio, the model of the project defaults to the Fleet Management model. This is annoying because you always have to manually change the model of the project to your own model before you can start working.

Perhaps even more annoying is that it's not possible to change the default model in the Visual Studio Dynamics 365 Options Add-In.


Lucky for us there's an easy fix for this by simply editing the DynamicsDevConfig.xml file located on the Dev Virtual Machine.


Here's how to change the default model in Visual Studio on your Dev Virtual Machine.


1. Close Visual Studio.

2. Locate the DynamicsDevConfig.xml file in C:\Users\<YourUserName>\Documents\Visual Studio 365\.

3. Change the DefaultModelForNewProjects value in DynamicsDevConfig.xml to preferred model.

4. Launch Visual Studio and create a new project to check if it's working.



Friday, 12 May 2023

Microsoft Dynamics 365 Finance - Multi selection on forms with the SysOperation framework

Background

The SysOperation framework works with a query to fetch any data that will be processed.

In some cases, it is required to only process the selected (marked) records on a form but this is not possible without some extra coding.

I have solved this using a temporary (temp) table. The RecIDs  of the selected records are added to the temp table and then the temp table is joined with the query of the data contract class.


License/disclaimer: Free to use. Use at own risk. You have no rights towards the author. There are no guarantees on the code.


Let's start.


Temporary  table

The code below uses a temp table and you will to create a temp table with a single RefRecId field. The data type of the field must be Int64, you can then extend from RefRecId.


Make sure the Table Type property is TempDB and CreatedBy property is enabled because the temp table query will filter on the current user.

Note: You could use one of the standard tables that contains a RefRecId field as long as the Table Type of the table is TempDB and the RefRecId field data type is Int64.


With the SysOperation framework, I usually create a minumum of three classes, the data contract, controller and the business logic or service class. There is also a helper class which is used for the multi selection and joining with the query.


Data contract

[DataContractAttribute, SysOperationAlwaysInitializeAttribute]

public final class AVSysOpMultiSelectTemplateContract implements SysOperationInitializable
{
    private str packedQuery, packedSelectedRecIds;

    public void initialize() //always called because of SysOperationAlwaysInitializeAttribute
    {
    }

    public Query getQuery()
    {
        return new Query(SysOperationHelper::base64Decode(packedQuery));
    }

    public void setQuery(Query _query)
    {
        packedQuery = SysOperationHelper::base64Encode(_query.pack());
    }

    //the query specified here is the query shown in the SysOp dialog and also the
    //query used in the SysOp service class.
    [DataMemberAttribute, AifQueryTypeAttribute('_packedQuery', queryStr(AVCustTable))]
    public str parmQuery(str _packedQuery = packedQuery)
    {
        packedQuery = _packedQuery;
        return packedQuery;
    }

    public Set getSelectedRecIdSet()
    {
        if (!packedSelectedRecIds)
        {
            throw error("You have to set the selected RecIds first.");
        }
        return Set::create(SysOperationHelper::base64Decode(packedSelectedRecIds));
    }

    public void setSelectedRecIdSet(Set _selectedRecIdSet)
    {
        if (_selectedRecIdSet == null)
        {
            throw error("Argument %1 cannot be null.");
        }
        packedSelectedRecIds = SysOperationHelper::base64Encode(_selectedRecIdSet.pack());
    }

    public boolean isMultiSelected()
    {
        return packedSelectedRecIds ? true : false;
    }
}


Controller

[SysOperationJournaledParametersAttribute(true)]

public final class AVSysOpMultiSelectTemplateController extends SysOperationServiceController
{
    public static void main(Args _args)
    {
        AVSysOpMultiSelectTemplateController operation = AVSysOpMultiSelectTemplateController::construct();
        operation.parmLoadFromSysLastValue(false); //called from the UI so do not call getLast, we don't want previous selections etc.
        //operation.initializeFromArgs(_args); //can call initializeFromArgs() if menu item contains SysOp execution info. if not, it removes SysOp execution info.
        operation.parmShowDialog(true);
        AVSysOpMultiSelectTemplateContract contract = operation.getDataContractObject();
        Debug::assert(contract != null);

        if (AVMultiSelectionHelper::isMultiSelected(_args))
        {
            contract.setSelectedRecIdSet(AVMultiSelectionHelper::getSelectedRecIDSet(_args));
        }
        else
        {
            operation.AVModifyQueryFromArgs(_args);
        }

        if (operation.startOperation() == SysOperationStartResult::Started) //will return result because we are running synchronous.
        {
            Counter cntr;
            [cntr] = operation.operationReturnValue();
            Info(strFmt("Processed %1 selected records.", cntr));
            //refresh caller here
        }
    }

    public void new(IdentifierName _className = '', IdentifierName _methodName = '', SysOperationExecutionMode _executionMode = SysOperationExecutionMode::Asynchronous)
    {
        super(_className, _methodName, _executionMode);
        
        this.parmClassName(classStr(AVSysOpMultiSelectTemplateService));
        this.parmMethodName(methodStr(AVSysOpMultiSelectTemplateService, runQuery));
        this.parmExecutionMode(SysOperationExecutionMode::Synchronous); //ScheduledBatch: batch job remains visible. ReliableAsynchronous: job is deleted.
        this.parmDialogCaption("AV multi select"); //set dialog caption.
    }

    public static AVSysOpMultiSelectTemplateController construct()
    {
        return new AVSysOpMultiSelectTemplateController();
    }

    public void AVModifyQueryFromArgs(Args _args)
    {
        if (!_args)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_args)));
        }

        if (_args.dataset() != tableNum(CustTable))
        {
            throw error(strFmt("Caller buffer must be %1.", tableStr(CustTable)));
        }

        CustTable custTable = _args.record();

        if (custTable.RecId != 0)
        {
            AVSysOpMultiSelectTemplateContract contract = this.getDataContractObject();
            Debug::assert(contract != null);
            if (contract != null)
            {
                Query query = contract.getQuery();
                Debug::assert(query != null);
                if (query != null)
                {
                    QueryBuildDataSource custTable_ds = query.dataSourceTable(tableNum(CustTable));
                    SysQuery::findOrCreateRange(custTable_ds, fieldNum(CustTable, AccountNum)).value(queryValue(custTable.AccountNum));
                    contract.setQuery(query);
                }
            }
        }
    }

    public boolean mustGoBatch()
    {
        boolean ret;
    
        ret = super();
    
        return ret;
    }

    public boolean showQuerySelectButton(str parameterName)
    {
        boolean ret;
    
        ret = super(parameterName);
        AVSysOpMultiSelectTemplateContract contract = this.getDataContractObject();
        Debug::assert(contract != null);

        if (contract.isMultiSelected())
        {
            ret = false;
        }
    
        return ret;
    }

    public boolean showQueryValues(str parameterName)
    {
        boolean ret;
    
        ret = super(parameterName);
        AVSysOpMultiSelectTemplateContract contract = this.getDataContractObject();
        Debug::assert(contract != null);

        if (contract.isMultiSelected())
        {
            ret = false;
        }
    
        return ret;
    }
}


Business logic

public final class AVSysOpMultiSelectTemplateService
{
    //the service method is a class method with a contract as argument. the contract contains the parameters and query (if specified) for the operation.
    //the contract specified here as argument is the contract for our SysOp controller and service classes.
    //this method does not have to return anything but it does return a container to illustrate how to return 'something' from the service method.
    public container runQuery(AVSysOpMultiSelectTemplateContract data)
    {
        Counter         cntr;
        AVTmpRecId      tmpRecId;    //table containing RecIds of the selected records.
        Set             tmpRecIdSet; //set containing the RecIds of the selected records.
        Query           query = data.getQuery();

        if (data.isMultiSelected())
        {
            tmpRecIdSet = data.getSelectedRecIdSet(); //get selected RecIds as a Set.
            AVMultiSelectionHelper::populateTmpRecIdFromSet(tmpRecIdSet, tmpRecId); //populate our temp table from the set that contains the RecIds.
            AVMultiSelectionHelper::modifyQueryAddJoinToTmpTable(query); //modify the query to join with our temp table.
        }

        QueryRun queryRun = new QueryRun(query);

        if (data.isMultiSelected())
        {
            queryRun.setRecord(tmpRecId); //if joined with a temp table, we must set the record on the queryRun object.
        }

        while (queryRun.next())
        {
            CustTable custTable = queryRun.get(tableNum(CustTable));
            info(custTable.AccountNum);
            cntr++;
        }

        return [cntr];
    }

}


Helper class

public final class AVMultiSelectionHelper
{
    public static Set getSelectedRecIDSet(Args _args)
    {
        Set selectedRecIdSet;

        if (!_args)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_args)));
        }

        if (!_args.dataset())
        {
            throw error("Must be called with a caller buffer.");
        }

        if (!_args.record().isFormDataSource())
        {
            throw error("The caller buffer must be form datasource.");
        }

        selectedRecIdSet = new Set(Types::Int64);

        MultiSelectionHelper multiSelectionHelper = MultiSelectionHelper::construct();
        multiSelectionHelper.parmDatasource(_args.record().dataSource());

        Common buffer = multiSelectionHelper.getFirst();

        while (buffer.RecId != 0)
        {
            selectedRecIdSet.add(buffer.RecId);
            buffer = multiSelectionHelper.getNext();
        }

        return selectedRecIdSet;
    }

    public static boolean isMultiSelected(Args _args)
    {
        Set selectedRecIdSet = AVMultiSelectionHelper::getSelectedRecIDSet(_args); //arg checks are done in getSelectedRecIDSet().
        return selectedRecIdSet.elements() > 1 ? true : false;
    }

    public static void modifyQueryAddJoinToTmpTable(Query    _query)
    {
        QueryBuildDataSource    qbds;
        SysDictTable            dictTable;
        QueryBuildDataSource    tmpRecId_ds;

        if (!_query)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_query)));
        }

        qbds = _query.dataSourceNo(1); //join on first datasource in query.
        dictTable = SysDictTable::newTableId(qbds.table());
        Debug::assert(dictTable != null);

        tmpRecId_ds = qbds.addDataSource(tableNum(AVTmpRecId), tableStr(AVTmpRecId));
        tmpRecId_ds.addLink(dictTable.fieldName2Id(identifierStr(RecId)), fieldNum(AVTmpRecId, RefRecId));
        qbds.clearRanges(); //joined so clear all existing ranges.

        SysQuery::findOrCreateRange(tmpRecId_ds, fieldNum(AVTmpRecId, CreatedBy)).value(curUserId());                                    
        SysQuery::findOrCreateRange(tmpRecId_ds, fieldNum(AVTmpRecId, CreatedTransactionId)).value(queryValue(appl.curTransactionId()));

    }

    public static void populateTmpRecIdFromSet(Set  _recIdSet, AVTmpRecId    _tmpRecId)
    {
        if (!_recIdSet)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_recIdSet)));
        }

        SetEnumerator enum = _recIdSet.getEnumerator();

        while (enum.moveNext())
        {
            _tmpRecId.RefRecId = enum.current();
            _tmpRecId.insert();
        }
    }

} 


Sunday, 7 March 2021

Microsoft Dynamics 365 Finance - OneBox Virtual Machine - How to download VHD files fast with AzCopy.exe

 

Background

It's possible to download a complete working Microsoft Dynamics 365 Finance virtual machine (VM) called OneBox from the LifeCycle services (LCS) asset library. By the way, this VM will only run with Hyper-V and will not run with other virtualisation software.

This VM can run locally on your companys infrastructure or even on your laptop provided you have a solid state disk (SSD) with enough space and your laptop has enough memory. My Dynamics 365 Finance VM takes up about 120GB of disk space on my external SSD drive.

I enabled Hyper-V on my laptop in Windows features and I am able to run the Dynamics 365 Finance VM with 24GB of RAM allocated to the VM and 8GB RAM for my Windows 10 host. The VM runs okay but it's definitely not suitable for full time development.

When I downloaded the 12 virtual hardisk (VHD) part files for the first time using with my browser, I discovered that it took a long time to download the files and many of the downloads failed. I had to retry them and it was just painful. Each file is about 3 GB. The separate part files, or volumes, will become one large VHD file when executing the first part file which is an executable.

In the LCS in asset library, on each downloadable file, I saw a button with the text "Generate SAS link". When I clicked on it, I got a message that the "SAS link for the asset has been copied to my clipboard and that I can use AzCopy to download the the asset".

I researched AzCopy a bit and downloaded a copy from the Microsoft site to my laptop and extracted it to a folder.

AzCopy can download files much faster than a browser from BLOB storage would because it uses multi threading to download different parts of the file at the same time. A browser uses a single thread per file being downloaded.

You can compare it to a race to empty two identical swimming pools, containing exactly the same amount of water manually just using buckets. With the first swimming pool, there is just one person emptying the pool but with the other, there are ten persons. Not really fair is it? But the ten persons will empty the pool a lot faster than one right?

I selected the first VHD file and clicked on the "Generate SAS link" button. I pasted the link to a text editor to create a command for AzCopy. I  opened a command prompt and downloaded the first file using AxCopy.

To make things easier, I got the idea to add the separate AzCopy command lines in a PowerShell script, one command for each file part . The PowerShell script can download all the part files one after each the other.


Create PowerShell script

  1. Download azcopy and extract it in a folder somewhere like c:\azcopy.
  2. Create a new text file called "downloadvhd.ps1" in the same folder as where azopy.exe is located.
  3. Log in to LCS and go to the Asset Library of your project.
  4. Select the "Downloadable VHD" asset type on the left. If you don't have any part files listed on the right, import them from the Shared Library.
  5. Select the first Part01 VHD file and click on the "General SAS link" button for that file. This will copy the link to  the clipboard.
  6. Open the file created in step 2. with a text editor. On the first line, type: .\azcopy.exe copy '
  7. After the aposthophe, paste the SAS link from the clipboard by pressing Control + V or Paste from the text editor menu. Make sure there is no space between the aposthrophe and the SAS link.
  8. The first part file is an executable file, the rest of the part files are RAR archive volume files with the .rar extension. After the SAS link, type a closing aposthrophe a space and the destination file name e.g.: ' c:\temp\FinandOps10.0.13.part01.exe
  9. For each part file in LCS, create a line in the script file, their file names will be FinandPos10.0.13.part02.rar, FinandPos10.0.13.part03.rar and so on. Important: Click on the "Generate SAS link" button for each file and paste it in the file for the corresponding part file.
  10. When all part files command are in the script file, save it and execute it.

Example PowerShell file

Replace SAS-link-for-Partxx with your own SAS links and change the file names.

.\azcopy.exe copy 'SAS-link-for-Part01' C:\temp\FinandOps10.0.13.part01.exe
.\azcopy.exe copy 'SAS-link-for-Part02' C:\temp\FinandOps10.0.13.part02.rar
.\azcopy.exe copy 'SAS-link-for-Part03' C:\temp\FinandOps10.0.13.part03.rar
.\azcopy.exe copy 'SAS-link-for-Part04' C:\temp\FinandOps10.0.13.part04.rar
.\azcopy.exe copy 'SAS-link-for-Part05' C:\temp\FinandOps10.0.13.part05.rar
.\azcopy.exe copy 'SAS-link-for-Part06' C:\temp\FinandOps10.0.13.part06.rar
.\azcopy.exe copy 'SAS-link-for-Part07' C:\temp\FinandOps10.0.13.part07.rar
.\azcopy.exe copy 'SAS-link-for-Part08' C:\temp\FinandOps10.0.13.part08.rar
.\azcopy.exe copy 'SAS-link-for-Part09' C:\temp\FinandOps10.0.13.part09.rar
.\azcopy.exe copy 'SAS-link-for-Part10' C:\temp\FinandOps10.0.13.part10.rar
.\azcopy.exe copy 'SAS-link-for-Part11' C:\temp\FinandOps10.0.13.part11.rar

Syntax

azcopy.exe copy [source] [destination]

Example:
azcopy.exe copy 'SAS-link-for-Part01' C:\temp\FinandOps10.0.13.part01.exe

Notes

The first part file is an executable file which will extract and combine all the part files into a single VHD file.

You have to check the file names in LCS and adjust the file names  in the script accordingly to match the version in LCS. Don't use my example version.

The SAS link is enclosed in single apostrophies. If the file names contain spaces, then the file name part must also be enclosed with single apostrophies.

Download in progress

Download VHD files from LCS with AzCopy