How to open windows desktop applications from Acumatica

Imagine you are working on Acumatica customization that needs to integrate with an existing desktop application. How can you launch the desktop application from the web-based app? It might seem impossible at first, but on Windows, it's actually quite simple. The key is to use Custom Protocol Handlers. All you need to do is install a new custom protocol and tell Windows which application should handle it. For example, let's say you have a desktop application that performs sales analysis based on the stock item when it is launched. You can create a new custom protocol called " ItemAnalyzer://" and whenever a URL with this protocol is entered into the browser, the desktop application will be launched and the text after the protocol will be treated as a parameter.

 

It's important to note that when using protocol handlers, the protocol name itself will be included as part of the argument passed to the desktop application. This may require some additional processing to remove the protocol name (such as the "GetStringBetweenDelimiters" function on line 17). For example, if you run the desktop application with an argument, you might get something like the following:

class Program
{
    static void Main(string[] args)
    {
        string inventoryCD = GetStringBetweenDelimiters(args[0]);
 
        Console.WriteLine($"Processing...: {inventoryCD}");
        // ...
        // do something with inventoryCD
        // ...
 
        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
 
    static string GetStringBetweenDelimiters(string input)
    {
        int firstIndex = input.IndexOf("://") + 3;
        int lastIndex = input.IndexOf('/', firstIndex);
        return input.Substring(firstIndex, lastIndex - firstIndex);
    }
 
    static void RegProtocol()
    {
        var key = Registry.ClassesRoot.CreateSubKey("ItemAnalyzer");
        
        key.SetValue("""URL:ItemAnalyzer Protocol");
        key.SetValue("URL Protocol""");
        
        var subKey = key.CreateSubKey(@"shell\open\command");
        var execPath = Path.Combine(System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location),
                                    System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName);
 
        subKey.SetValue(""$"{execPath} %1");
        subKey.Close();
        key.Close();
    }
}

 

To make the magic happen, we need to register the custom protocol handler in the Windows registry. This can be done manually or automatically. First, let's do it manually. To do this, open the Windows registry as a system administrator (type "Regedit" in the start menu or run it as a command). Then, follow these steps:

 

  1. Under HKEY_CLASSES_ROOT, create a new key with the same name as the protocol (in this case, " ItemAnalyzer ").
  2. Inside the new key, add a default new string value with no name (just "Default") and set its content to "URL:protocol_name Protocol" (in this case, "URL: ItemAnalyzer Protocol").

3. Add a new string with the name "URL Protocol" and no content.

4. Under the " ItemAnalyzer" key, add the following keys hierarchically: shell\open\command

5. Inside the "command" key, add a new string with an empty name (just "Default") and set its value to the location of the executable followed by %1, which represents the argument to pass to the executable.

After completing these steps, if you open the run window and type "ItemAnalyzer:// " and press enter, the application will be launched. You can also do this from the browser, and the browser will prompt you for confirmation before launching the application.

Now, let's proceed to the implementation of an action within Acumatica that will initiate the opening of a desktop application when activated. Specifically, we want to create an action on the Sales Order screen that, will execute a specified program to run our desktop application and pass the InventoryCD as an argument.

namespace AcuStockItemAnalizer
{
    public class SOOrderEntryExt : PXGraphExtension<SOOrderEntry>
    {
        public static bool IsActive() => true;
 
        #region Action
        public PXAction<SOOrder> RunStockItemAnalyzer;
        
        [PXUIField(DisplayName = "Run Item Analyzer")]
        [PXButton(CommitChanges = true)]
        protected virtual IEnumerable runStockItemAnalyzer(PXAdapter adapter)
        {
            var tranRow = Base.Transactions.Current;
            if (tranRow != null)
            {
                var inventoryItem = PXSelectorAttribute.Select<SOLine.inventoryID>(Base.Caches[typeof(SOLine)], tranRow, tranRow?.InventoryID) as InventoryItem;
                if (inventoryItem != null)
                {
                    string urlProtocol = string.Format($"ItemAnalyzer://{inventoryItem.InventoryCD}");
                    throw new PXRedirectToUrlException(urlProtocol, null);
                }
            }
            
            return adapter.Get();
        }
        #endregion
    }
}

As demonstrated in the example, activating the action initiates the opening of a desktop application, with the InventoryCD being passed as a parameter.

 

To automate the process of registering a custom protocol handler, you can change the registry during the application installation or have the application do it automatically. One way to do this in C# is to use the code provided.

Another option is to create a .REG file. This is a plain text file with a .REG extension that contains registry entries, and it can be used to add or modify registry entries automatically when opened. When you double-click a .REG file, it will be imported into the registry, and the registry entries it contains will be added or modified. This can be a convenient way to automatically register custom protocol handlers without manually editing the registry.

 

Windows Registry Editor Version 5.00

 

[HKEY_CLASSES_ROOT\ItemAnalyzer]

@="URL: ItemAnalyzer Protocol"

"URL Protocol"=""

 

[HKEY_CLASSES_ROOT\ItemAnalyzer\shell]

 

[HKEY_CLASSES_ROOT\ItemAnalyzer\shell\open]

 

[HKEY_CLASSES_ROOT\ItemAnalyzer\shell\open\command]

@="\"C:\\TestApplication\\StockItemAnalizer.exe\" \"%1\""

 

Acumatica: SMS Provider, Twilio SMS provider, send SMS in action

Hello everybody,

Today I want to share one approach, how to send SMS message from custom action in Acumatica.

Acumatica has several sms providers in SalesDemo data base, it depends from Acumatica’s version, so, we will use Twilio provider. On SMS Provider screen you can find authorization parameters from each provider, as on screen shot from (22r1 build):

First you need to add two references from Acumatica’s Bin folder to your project in VS:

PX.SmsProvider.Core.dll

PX.SmsProvider.UI.dll

 

Then create graph extension for any screen that you need and develop next logic in custom button “Send SMS”.

Also, you need to use Dependency Injection (ASP.net) and IReadOnlyDictionary interface and define field in graph extension with ISmsProvider type.

The main logic consists from next steps: prepare list of settings (List<ISmsProviderSetting>), create SmsProvider fabric (using dependency injection), load setting to fabric, prepare SMS message and send it in async mode. Also, you can add PXLongOperation feature in action, it doesn’t have conflicts with async method.

Source code example here:

public class SOOrderEntryExt : PXGraphExtension<SOOrderEntry>
{
    public static bool IsActive() => true;
 
 
    [InjectDependency]
    internal IReadOnlyDictionary<string, ISmsProviderFactory> ProviderFactories { getset; }
    private ISmsProvider _currentProvider;
 
 
    public PXAction<SOOrder> SendSMS;
    [PXButton()]
    [PXUIField(DisplayName = "Send SMS", MapEnableRights = PXCacheRights.Select, MapViewRights = PXCacheRights.Select)]
    protected virtual void sendSMS()
    {
        SendSMSNotification(this.Base, "+380990123456""Test SMS message from Acumatica server");
    }
 
    public virtual void SendSMSNotification(PXGraph graphstring phonestring smsMessage)
    {
        string parsePhoneTo = phone.Trim(' ''('')''-');
 
        var selectProviderAuth = SelectFrom<SmsPluginParameter>.InnerJoin<SmsPlugin>.On<SmsPluginParameter.pluginName.IsEqual<SmsPlugin.name>>.
            Where<SmsPlugin.isDefault.IsEqual<@P.AsBool>>.View.Select(graph, true);
 
        string ACCOUNT_SID = string.Empty;
        string SECRET = string.Empty;
        string FROM_PHONE_NBR = string.Empty;
 
        var setting = new List<TGSmsProviderSettings>();
 
        foreach (SmsPluginParameter item in selectProviderAuth)
        {
            if (item.PluginTypeName != "PX.SmsProvider.Twilio.TwilioVoiceProvider")
                throw new PXException("No preferences for Twilio provider!");
 
            switch (item.Name)
            {
                case nameof(ACCOUNT_SID):
                    {
                        ACCOUNT_SID = item.Value;
                        setting.Add(new TGSmsProviderSettings()
                        {
                            Name = nameof(ACCOUNT_SID),
                            Value = ACCOUNT_SID,
                        });
                        break;
                    }
                case nameof(SECRET):
                    {
                        SECRET = item.Value;
                        setting.Add(new TGSmsProviderSettings()
                        {
                            Name = nameof(SECRET),
                            Value = SECRET,
                        });
                        break;
                    }
                case nameof(FROM_PHONE_NBR):
                    {
                        FROM_PHONE_NBR = item.Value;
                        setting.Add(new TGSmsProviderSettings()
                        {
                            Name = nameof(FROM_PHONE_NBR),
                            Value = FROM_PHONE_NBR,
                        });
                        break;
                    }
            }
        }
 
        if (string.IsNullOrEmpty(ACCOUNT_SID) || string.IsNullOrEmpty(FROM_PHONE_NBR) || string.IsNullOrEmpty(SECRET))
            throw new PXException("No preferences for Twilio provider!");
 
        SendSmSMessage(setting, parsePhoneTo, smsMessage);
    }
 
    private void SendSmSMessage(List<TGSmsProviderSettings> settingsstring phonestring body)
    {
        if (this._currentProvider != nullreturn;
 
        this._currentProvider = this.ProviderFactories["PX.SmsProvider.Twilio.TwilioVoiceProvider"].Create();
        this._currentProvider.LoadSettings(settings);
 
        var messageRequest = new SendMessageRequest()
        {
            RecepientPhoneNbr = phone,
            RecepientSMSMessage = body
        };
 
        try
        {
            _currentProvider.SendMessageAsync(messageRequest, CancellationToken.None).Wait();
        }
        catch (AggregateException ex)
        {
            string str = string.Join(";", ex.InnerExceptions.Select(x => x.Message));
            throw new PXException(str);
        }
    }
}
 
 
public class TGSmsProviderSettings : ISmsProviderSetting
{
    public string Name { getset; }
    public string Description { getset; }
    public string Value { getset; }
}

Summary

With provided code and Twilio you can send sms messages to your USA based customers. Similar activites may be done for other customers, but out of the box Acumatica allows to use Twilio.

Holywar: is Acumatica truly cloud ERP?

Hi,

in the video below I'm discussing, why Acumatica is cloud ERP, byt why even Acumatica developers may not be aware of that.

Hopefully it will help you to explain the matters a bit better to technical users.

CRM VS ERP

Hi,

here is short video, which will help you to make a decision, which one is better for you:

Thanks for watching!

PXAggregateAttribute usage for saving of your development time

Hi everybody,

today I want to share one of the insights from code and code, which was conducted by Stéphane Bélanger, and which seems useful, also may be controversial. But still, you may like it. So let me introduce or re-introduce PXAggregateAttribute .

If to sum up, purpose of PXAggregateAttribute, is merging of couple of attributes into single one. Consider following situation. You need to have selector Active customer over multiple places in Acumatica: at Purchase orders form, and Sales order form and at Shipment form. And difference between them will be zero, or close to that. Wouldn't that be nice, to declare this attribute in one place, and then to re-use it everywhere else? Of course yes. And for that purpose, Acumatica introduced attribute PXAggregateAttribute. 

Below goes code sample, of how that can be used, and re-used:

[PXInt()]
[PXUIField(DisplayName = "Active Customer")]
[PXDefault()]
[PXSelector(typeof(Search<BAccountR.bAccountID,
    Where<BAccountR.status, Equal<BAccount.status.active>>>))]
public class ActiveCustomer : PXAggregateAttribute {}
 
 
public class SOShipmentExt : PXCacheExtension<SOShipment>
{
    public abstract class activeCustomer : PX.Data.BQL.BqlInt.Field<activeCustomer> { }
 
    // Acuminator disable once PX1030 PXDefaultIncorrectUse [For demonstration purposes that will be sufficient]
    [ActiveCustomer]
    public Int32? ActiveCustomer { getset; }
}
 
public class POOrderExt : PXCacheExtension<POOrder>
{
    public abstract class activeCustomer : PX.Data.BQL.BqlInt.Field<activeCustomer> { }
 
    // Acuminator disable once PX1030 PXDefaultIncorrectUse [For demonstration purposes that will be sufficient]
    [ActiveCustomer]
    public Int32? ActiveCustomer { getset; }
}
 
public class SOOrderExt : PXCacheExtension<SOOrder>
{
    public abstract class activeCustomer : PX.Data.BQL.BqlInt.Field<activeCustomer> { }
 
    // Acuminator disable once PX1030 PXDefaultIncorrectUse [For demonstration purposes that will be sufficient]
    [ActiveCustomer]
    public Int32? ActiveCustomer { getset; }
}

 As you can see, above our class ActiveCustomer, we've declared bundle of attributes, and then everywhere else, we've used them, but instead of duplication of code, we've re-used them as single line.

 

Change field state dynamically in Acumatica. Or changing field type in Acumatica

Hello friends.

Today I will tell you how we can dynamically change the state of the field in a grid. I want to notice that this way works only for grid and will not work with Form.

In our case we will create DAC Extension for SOLine with 2 fields.

First field will choose the type we want to convert the field into and in the second field will interact with it.

In this example I made fields PXDBString on purpose to show how they are saved to the database.

[PXCacheName(SoLineExtCacheName)]
public class SoLineExt : PXCacheExtension<SOLine>
{
    private const string SoLineExtCacheName = "SoLineExt";
    public static bool IsActive() => true;
 
    #region UsrSlsOrdPrimaryReasonCode
 
    [PXDBString(255)]
    [PXStringList(
        new[] { "1""2""3""4""5" },
        new[] { "DropDown""TextBox""DateTime""CheckBox""Selector" })]
    [PXUIField(DisplayName = "FieldOne")]
    public string UsrFieldOne { getset; }
 
    public abstract class usrFieldOne : BqlString.Field<usrFieldOne>
    {
    }
 
    #endregion
 
    #region UsrSlsOrdSecondaryReasonCode
 
    [PXDBString(255)]
    [PXUIField(DisplayName = "FieldTwo")]
    public string UsrFieldTwo { getset; }
 
    public abstract class usrFieldTwo : BqlString.Field<usrFieldTwo>
    {
    }
 
    #endregion
 
}

 

The next step should be creating the GraphExtension for the graph.

Create a FieldSelecting event for UsrFieldTwo that will dynamically change the state.

We will also check if UsrFieldOne is empty then by default we can create our UsrFieldTwo as a text field.

[PXCacheName(SoOrderEntryExtCacheName)]
public class SOrderEntryExt : PXGraphExtension<SOOrderEntry>
{
    private const string SoOrderEntryExtCacheName = "SoOrderEntryExt";
    public static bool IsActive() => true;
 
    public PXSelect<FixedAsset> FixedAssets; // Here we've a view that we'll show in a selector
 
    protected void _(Events.FieldSelecting<SOLine, SoLineExt.usrFieldTwo> args)
    {
        var fieldOne = args.Row.GetExtension<SoLineExt>()?.UsrFieldOne;
        if (args.Row == null || string.IsNullOrWhiteSpace(fieldOne))
        {
            return;
        }

 

The next step is to define a Switch conditional construct in which we will check the type we should transform UsrFieldTwo into and return to text field.

        switch (fieldOne)
        {
            case "1":
                args.ReturnState = PXStringState.CreateInstance(args.ReturnState, 100, truetypeof(SoLineExt.usrFieldTwo).Name,
                    false, -1, string.Empty, new[] { "val_1""val_2""val_3", }, new[] { "val_1""val_2""val_3", }, falsenull);
                // We can uncomment this line if need to MultiSelect in dropdown.
                //((PXStringState)args.ReturnState).MultiSelect = true;
                break;
            case "2":
                args.ReturnState = PXStringState.CreateInstance(args.ReturnState, 100, null,
                    typeof(SoLineExt.usrFieldTwo).Name, false, -1, nullnullnulltruenull);
                break;
            case "3":
                args.ReturnState = PXDateState.CreateInstance(args.ReturnState, typeof(SoLineExt.usrFieldTwo).Name, false, -1,
                    nullnullnullnull);
                break;
            case "4":
                args.ReturnState = PXFieldState.CreateInstance(args.ReturnState, typeof(bool), falsefalse, -1,
                    nullnullfalsetypeof(SoLineExt.usrFieldTwo).Name, nullnullnull, PXErrorLevel.Undefined, truetrue,
                    null, PXUIVisibility.Visible, nullnullnull); break;
            case "5":
                var state = PXFieldState.CreateInstance(args.ReturnState,
                    typeof(string), falsetrue, 1, nullnullnulltypeof(SoLineExt.usrFieldTwo).Name);
                state.ViewName = nameof(FixedAssets);
                state.DescriptionName = nameof(FixedAsset.description);
                state.FieldList = new[]
                {
                    nameof(FixedAsset.assetID),
                    nameof(FixedAsset.description),
                    nameof(FixedAsset.assetTypeID),
                    nameof(FixedAsset.assetCD)
                };
                var selectorCache = Base.Caches<FixedAsset>();
                state.HeaderList = new[]
                {
                    PXUIFieldAttribute.GetDisplayName<FixedAsset.assetID>(selectorCache),
                    PXUIFieldAttribute.GetDisplayName<FixedAsset.description>(selectorCache),
                    PXUIFieldAttribute.GetDisplayName<FixedAsset.assetTypeID>(selectorCache),
                    PXUIFieldAttribute.GetDisplayName<FixedAsset.assetCD>(selectorCache),
                };
                state.DisplayName = PXUIFieldAttribute.GetDisplayName<SoLineExt.usrFieldTwo>(args.Cache);
                state.Visible = true;
                state.Visibility = PXUIVisibility.Visible;
                state.Enabled = true;
                args.ReturnState = state;
                break;
            default:
                args.ReturnState = PXStringState.CreateInstance(args.ReturnState, 100, null,
                    typeof(SoLineExt.usrFieldTwo).Name, false, -1, nullnullnulltruenull);
                break;
        }
 
    }
}

 After we have prepared our GraphExtension and CacheExtension we need to add our fields to the View, so we can do this through the Customization Editor for clarity.

Very important point! Since we dynamically change the type of our field UsrFieldTwo, you must set MatrixMode="true" for this column:

That's it, now we can make a publish and check it out.

You must remember that all fields that are specified in the list in the database will be stored as a string, so do not forget to convert them to the correct type when you work with them, to avoid problems with the type of ghosting.

If the field is empty then by default it will be a text field.

  • If you select dropdown then we get the value we set in e.ReturnState.

  • The text field is identical to the empty field.

  • If you select CheckBox, our field will have two states True or False.

  • If we select DateTime we can select a date from the DateTimePicker.

 

  • Of course, Selector. This will display the data from our previously defined FixedAsset view.

Let's also see how this data is stored in the database which is demonstrated by sampling.

That's all for now, thank you for your attention, I hope this article will be useful for you

All for successful coding.

 

How to add GI to side panel and Pivot table to side panel

Hello everybody,

Today a want to share with you approach how to add GI to Side Panel, add Pivot Table to Side Panel, and how current row (current field value) of screen bounds with filter of GI and PT.

Also, I will show how to add all custom features with GI and PT to customization package.

As example, we will add side panel to Customers screen and add two actions, first - custom GI report about sales, second – pivot table with information about Sales Order that connected to current customer on screen.

  1. First, we create custom GI report with joins of tables, result grid with columns that we need for pivot table.

Pivot tables in Acumatica are created and based on GI. All columns in ResultGrid tab will able in pivot table.

 

Add Parameter and Conditions for it.

Click button “VIEW INQUIRY” and check how works result and filter:

Create and setup pivot table as we need:

Click “Save as Pivot”, enter name of pivot table, select check box “Shared Configuration”

Then Acumatica show additional tab with Pivot Table as on screen-shot

Setup Rows, Columns and Values for pivot table, then unclick “Edit pivot table” button  

Pivot table will look as on screen-shot. Also you can drag and drop field between Rows, Columns and Values on pivot table result as you need:

Add side panel to Customer screen (AR303000).

First create new customization package and add AR303000 screen to it.

Save new action in customization and publish customization. Check work side panel with GI on Customers screen:

Add Pivot table to side panel.

Acumatica hides tab of GI with pivot table on side panel and we can see only GI report grid on side panel.

But it is opportunity in Acumatica to add pivot table to side panel using dashboard.

Also we will setup dashboard filter and pipeline it with current customer and pivot table.

  • Create custom dashboard and add parameters as on screen-shot:

Click “VIEW” and setup layout of dashboard

Add pivot table to dashboard: select our custom GI report and Pivot Table (required fields):

Click “FILTER SETTINGS”, add a new and setup it:

Click “FINISH” and exit from DESING mode of dashboard.

Add dashboard with pivot table to side panel.

Open customization package, add new action with side panel type to Customer screen. Select our dashboard ass Destination Screen, also setup Navigation Parameters:

Click OK and Publish customization.

Check how works side panel with Pivot Table. Click “Next” on screen and check how pivot table changes data, depends from current customer:

 

How to get started with Acumatica development

Hi everybody,

below goes video, which describes on how to get started with Acumatica development:

In that video you'll find out:

1. How to install specific Acumatica build

2. How to debug C# code written by you in Visual Studio and Customization designer

3. How to get possibility to debug Acumatica source code

4. Default user name and password of Acumatica instnace

And much much more. Please watch and support with your likes!

 

 

New logging features of Acumatica

Hello everybody,

today I want to compare two logging mechanisms of Acumatica:

  • PXTrace
  • ILogger of Microsoft.Extensions.Logging

 

In order to compare them, I've did following:

1. modified web.config

2. created C# class

Modification section pxtrace of web.config goes below:

    <pxtrace>
	    <providers>
		<remove name="PXSessionTraceProvider" />
		<add name="PXSessionTraceProvider" type="PX.Data.PXSessionTraceProvider, PX.Data"
		     url="../Frames/Trace.aspx" />
		<remove name="PXFileTraceProvider" />
		<add name="PXFileTraceProvider" type="PX.Data.PXFileTraceProvider, PX.Data"
		     file = "d:\Instances\22.202.0040\SignalR\out\AcumaticaTraceInfo.txt" />
		</providers>
    </pxtrace>

 Code goes below:

public class SOShipmentEntryExt : PXGraphExtension<SOShipmentEntry>
{
    public static bool IsActive() => true;
 
    [InjectDependency]
    private ILogger<SOShipmentEntry> MyLogger { getset; }
 
    public PXAction<SOShipment> trace;
 
    [PXButton(CommitChanges = true), PXUIField(DisplayName = "Trace", MapEnableRights = PXCacheRights.Select)]
    public IEnumerable Trace(PXAdapter adapter)
    {
        var sw = new Stopwatch();
        const int n = 10000;
        sw.Start();
        for (int i = 0; i < n; i++)
        {
            PXTrace.WriteInformation("Test message tracer");
        }
        sw.Stop();
 
        PXTrace.WriteInformation("------------------------------------- trace " + sw.ElapsedMilliseconds);
 
        sw.Start();
        for (int i = 0; i < n; i++)
        {
            MyLogger.LogInformation("Test message logger");
        }
        sw.Stop();
 
        PXTrace.WriteInformation("------------------------------------- logger " + sw.ElapsedMilliseconds);
 
        return adapter.Get();
    }
}

 if to sum up, these two changes do following:

1. Put all information into file AcumaticaTraceInfo.txt

2. Button is created on the form shipments: so302000

3. If user clicks on that button, then 10 000 logging messages are created using PXTrace

4. 10 000 logging messages, through Microsoft.Extensions.Logging

Additional tables

 Besides that, want to mention following tables in Acumatica, which has some loggings.

LoginTrace: All login, logout, form opening, session expiration, and customization publishing events
UPSnapshotHistory: All snapshot restore operations
UPHistory: All application updates
AUScheduleHistory: All schedule execution events
AuditHistory: All field changes (if auditing is enabled)
EMailSyncLog: All exchange synchronization operations
AUNotificationHistory: All notification operations
PushNotificationsErrors: Errors during creation of push notifications
PushNotificationsFailedToSend: Information on the push notifications that Acumatica ERP failed to send to the notification destination within the last two days

Usage of SignalR and javascript in Acumatica

Hi everybody,

 

 

today I want to describe how you can use SignalR and javascript in Acumatica. I will describe following functionality:

1. User 1 opens sales order SO006768

2. User 2 opens sales order SO006768

3. User 1 modifies Sales order, and clicks on Save button

4. User 2 gets following notification:

One of the ways to achieve that, is to use SignalR and a bit of jQuery.

In order to achieve that, following steps are needed:

1. Create interface, which will be a backbone of functionality:

public interface ISalesOrderNotify
{
    Task<stringOrderWasChanged(string refNbr);
}

 2. Create class, which will bound interface implementation to SignalR of Acumatica:

public class SOOrderHub : Hub<ISalesOrderNotify>
{
    public async Task<stringNotify(string refNbr)
    {
        await Clients.Others.OrderWasChanged(refNbr);
        return await Clients.Caller.OrderWasChanged(refNbr);
    }
}

 What I want to specifically highlight, is inheritance from the class Hub, which bounds SignalR with Interface and particular implementation.

3. Inform Acumatica framework about such connection:

public class ServiceRegistration : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterType<SOOrderHub>().ExternallyOwned();
    }
}

4. And finally, explain in graph or graph extension, how steps 1 - 3 will be used:

public class SOOrderentryExt : PXGraphExtension<SOOrderEntry>
{
    public static bool IsActive() => true;
 
    [InjectDependency]
    internal IConnectionManager SignalRConnectionManager { getset; }
 
    public override void Initialize()
    {
        base.Initialize();
        var hubContext = GlobalHost.ConnectionManager.GetHubContext<SOOrderHub>();
 
    }
 
    [PXOverride]
    public void Persist(Action basePersist)
    {
        basePersist();
        var currentOrder = Base.CurrentDocument.Current.RefNbr;
 
        var cnt = SignalRConnectionManager.GetHubContext<SOOrderHub>();
        cnt.Clients.All.OrderWasChanged(currentOrder);
    }
}
5. In your aspx.cs mention, that you want to have SignalR and jquery:
protected void Page_Init(object sender, EventArgs e)
	{
		Master.PopupWidth = 950;
		Master.PopupHeight = 600;
        // panel = (PXFormView)this.PanelAddSiteStatus.FindControl("formSitesStatus");
 
        this.ClientScript.RegisterClientScriptInclude(this.GetType(), "jq", VirtualPathUtility.ToAbsolute("~/Scripts/jquery-3.1.1.min.js"));
        this.ClientScript.RegisterClientScriptInclude(this.GetType(), "jqsr", VirtualPathUtility.ToAbsolute("~/Scripts/jquery.signalR-2.2.1.min.js"));
        this.ClientScript.RegisterClientScriptInclude(this.GetType(), "hb", VirtualPathUtility.ToAbsolute("~/signalr/hubs"));
    }
6. In your aspx describe a bit of javascript logic:
<script type="text/javascript">
    var hubProxy = $.connection.sOOrderHub;
    hubProxy.connection.start()
        .done(function () {
                console.log("hub proxy started");
            }
        );
    hubProxy.on(
        "OrderWasChanged", function(refNbr) {
            var value = $("#ctl00_phF_form_t0_edOrderNbr_text").val();
            if (value === refNbr) {
                alert("Sales Order:" + refNbr + " was modified");
            }
        }
    );
</script>
Summary

With usage of such technique, you'll be able to connect your C# part to js in a bit more invisible way, and add a bit more interactivity. Couple of additional details I'll add later on my youtube video.