浏览代码

Added MYOB Puller for customers

Kenric Nugteren 1 年之前
父节点
当前提交
5b202390cd

+ 2 - 2
prs.desktop/Panels/Products/Locations/StockHoldingRelocationWindow.xaml

@@ -162,11 +162,11 @@
         <DockPanel Grid.Row="2" LastChildFill="False">
             <Button x:Name="CancelButton" Click="CancelButton_Click"
                     Content="Cancel"
-                    Margin="5" Padding="5" MinWidth="60"
+                    Margin="5,5,0,0" Padding="5" MinWidth="60"
                     DockPanel.Dock="Right"/>
             <Button x:Name="OKButton" Click="OKButton_Click"
                     Content="OK"
-                    Margin="5,5,0,5" Padding="5" MinWidth="60"
+                    Margin="5,5,0,0" Padding="5" MinWidth="60"
                     DockPanel.Dock="Right"
                     IsEnabled="{Binding CanSave}"/>
         </DockPanel>

+ 279 - 2
prs.desktop/Utils/PostUtils.cs

@@ -7,16 +7,107 @@ using InABox.Wpf;
 using InABox.WPF;
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using System.Drawing;
 using System.Globalization;
 using System.Linq;
 using System.Text;
 using System.Threading.Tasks;
 using System.Windows;
+using System.Windows.Controls;
 using System.Windows.Media.Imaging;
 
 namespace PRSDesktop;
 
+public class PullResultGrid<T> : DynamicItemsListGrid<T>
+    where T : BaseObject, IPostable, new()
+{
+    private static BitmapImage tick = InABox.Wpf.Resources.tick.AsBitmapImage();
+
+    private class ResultItem(PullResultItem<T> item, bool selected)
+    {
+        public PullResultItem<T> Item { get; set; } = item;
+
+        public bool Selected { get; set; } = selected;
+    }
+
+    public bool CanSave => _items.Any(x => x.Selected);
+
+    private List<ResultItem> _items;
+
+    public IEnumerable<PullResultItem<T>> Selected => _items.Where(x => x.Selected).Select(x => x.Item);
+
+    protected DynamicGridCustomColumnsComponent<T> ColumnsComponent;
+
+    public PullResultGrid(IPullResult<T> result)
+    {
+        _items = result.PulledEntities.Where(x => x.Item.PostedStatus != PostedStatus.PostFailed).Select(x => new ResultItem(x, false)).ToList();
+        Items = _items.Select(x => x.Item.Item).ToList();
+
+        ColumnsComponent = new DynamicGridCustomColumnsComponent<T>(this, typeof(T).Name);
+    }
+
+    protected override DynamicGridColumns LoadColumns()
+    {
+        return ColumnsComponent.LoadColumns();
+    }
+
+    protected override void SaveColumns(DynamicGridColumns columns)
+    {
+        ColumnsComponent.SaveColumns(columns);
+    }
+
+    protected override void LoadColumnsMenu(ContextMenu menu)
+    {
+        ColumnsComponent.LoadColumnsMenu(menu);
+    }
+
+    private ResultItem? GetItem(CoreRow? row)
+    {
+        return row is not null ? _items[_recordmap[row].Index] : null;
+    }
+
+    protected override void Init()
+    {
+        base.Init();
+
+        ActionColumns.Add(new DynamicImageColumn(Selected_Image, Selected_Click) { Position = DynamicActionColumnPosition.Start });
+        ActionColumns.Add(new DynamicTextColumn(row => GetItem(row)?.Item.Type.ToString() ?? "Action")
+        {
+            Position = DynamicActionColumnPosition.Start
+        });
+    }
+
+    private BitmapImage? Selected_Image(CoreRow? row)
+    {
+        var item = GetItem(row);
+        return (item is null || item.Selected)
+            ? tick
+            : null;
+    }
+
+    private bool Selected_Click(CoreRow? row)
+    {
+        var item = GetItem(row);
+        if(item is not null)
+        {
+            item.Selected = !item.Selected;
+            DoChanged();
+            return true;
+        }
+        return false;
+    }
+
+    protected override void DoReconfigure(DynamicGridOptions options)
+    {
+        base.DoReconfigure(options);
+
+        options.Clear();
+        options.SelectColumns = true;
+        options.FilterRows = true;
+    }
+}
+
 public static class PostUtils
 {
     private static readonly Inflector.Inflector inflector = new(new CultureInfo("en"));
@@ -144,12 +235,191 @@ public static class PostUtils
         } while (retry);
     }
 
+    public static bool ShowPullResultGrid<T>(IPullResult<T> result, [NotNullWhen(true)] out List<PullResultItem<T>>? items)
+        where T : Entity, IPostable, IRemotable, IPersistent, new()
+    {
+        var resultGrid = new PullResultGrid<T>(result);
+        var window = new DynamicContentDialog(resultGrid)
+        {
+            Title = "Select items to import:",
+            CanSave = false
+        };
+        resultGrid.OnChanged += (o, e) => window.CanSave = resultGrid.CanSave;
+        resultGrid.Refresh(true, true);
+        if(window.ShowDialog() == true)
+        {
+            items = resultGrid.Selected.ToList();
+            Client.Save(items.Select(x => x.Item), "Posted by user.");
+            return true;
+        }
+        else
+        {
+            items = null;
+            return false;
+        }
+    }
+
+    public static void PullEntities<T>(Action refresh, Action? configurePost = null)
+        where T : Entity, IPostable, IRemotable, IPersistent, new()
+    {
+        bool retry;
+        do
+        {
+            retry = false;
+            try
+            {
+                var result = PosterUtils.Pull<T>();
+                if (result is null)
+                {
+                    MessageWindow.ShowMessage($"Import failed", "Import failed");
+                    refresh();
+                }
+                else
+                {
+                    List<PullResultItem<T>>? items;
+                    if (!result.PulledEntities.Any(x => x.Item.PostedStatus != PostedStatus.PostFailed))
+                    {
+                        items = result.PulledEntities.ToList();
+                    }
+                    else
+                    {
+                        ShowPullResultGrid(result, out items);
+                    }
+                    if (items is null)
+                    {
+                        MessageWindow.ShowMessage("Import cancelled.", "Cancelled");
+                    }
+                    else
+                    {
+                        var failedMessages = new List<string>();
+                        var successCount = 0;
+                        var importCount = 0;
+                        var updateCount = 0;
+                        foreach (var item in items)
+                        {
+                            if (item.Item.PostedStatus == PostedStatus.PostFailed)
+                            {
+                                failedMessages.Add(item.Item.PostedNote);
+                            }
+                            else
+                            {
+                                successCount++;
+                                switch (item.Type)
+                                {
+                                    case PullResultType.New:
+                                        importCount++;
+                                        break;
+                                    case PullResultType.Updated:
+                                    case PullResultType.Linked:
+                                    default:
+                                        updateCount++;
+                                        break;
+                                }
+                            }
+                        }
+                        if (failedMessages.Count > 0 && successCount == 0)
+                        {
+                            MessageWindow.ShowMessage($"Import failed:\n - {string.Join("\n - ", failedMessages)}", "Import failed.");
+                        }
+                        else if (failedMessages.Count == 0)
+                        {
+                            if (successCount == 0)
+                            {
+                                MessageWindow.ShowMessage($"Nothing imported.", "Import successful.");
+                            }
+                            else
+                            {
+                                MessageWindow.ShowMessage($"Import successful; {importCount} items imported.", "Import successful.");
+                            }
+                        }
+                        else
+                        {
+                            MessageWindow.ShowMessage($"{successCount} items succeeded, but {failedMessages.Count} failed:\n - {string.Join("\n - ", failedMessages)}", "Partial success");
+                        }
+                        refresh();
+                    }
+                }
+            }
+            catch (PullFailedMessageException e)
+            {
+                MessageWindow.ShowMessage(e.Message, "Import failed");
+            }
+            catch (PullCancelledException)
+            {
+                MessageWindow.ShowMessage("Import cancelled.", "Cancelled");
+            }
+            catch (MissingSettingException e)
+            {
+                if (configurePost is not null && Security.CanConfigurePost<T>())
+                {
+                    if (MessageWindow.ShowYesNo($"'{e.Setting}' has not been set-up for {inflector.Pluralize(typeof(T).Name)}. Would you like to configure this now?",
+                        "Configure Import?"))
+                    {
+                        bool success = false;
+                        if (e.SettingsType.IsAssignableTo(typeof(IGlobalPosterSettings)))
+                        {
+                            success = PostableSettingsGrid.ConfigureGlobalPosterSettings(e.SettingsType);
+                        }
+                        else
+                        {
+                            success = PostableSettingsGrid.ConfigurePosterSettings<T>(e.SettingsType);
+                        }
+                        if (success && MessageWindow.ShowYesNo("Settings updated; Would you like to retry the import?", "Retry?"))
+                        {
+                            retry = true;
+                        }
+                        else
+                        {
+                            MessageWindow.ShowMessage("Import cancelled.", "Cancelled");
+                        }
+                    }
+                    else
+                    {
+                        MessageWindow.ShowMessage("Import cancelled.", "Cancelled");
+                    }
+                }
+                else
+                {
+                    MessageWindow.ShowMessage($"'{e.Setting}' has not been set-up for {inflector.Pluralize(typeof(T).Name)}", "Unconfigured");
+                }
+            }
+            catch (MissingSettingsException)
+            {
+                if (configurePost is not null && Security.CanConfigurePost<T>())
+                {
+                    if (MessageWindow.ShowYesNo($"Importing has not been configured for {inflector.Pluralize(typeof(T).Name)}. Would you like to configure this now?",
+                        "Configure Import?"))
+                    {
+                        configurePost();
+                    }
+                    else
+                    {
+                        MessageWindow.ShowMessage("Import cancelled.", "Cancelled");
+                    }
+                }
+                else
+                {
+                    MessageWindow.ShowMessage($"Importing has not been configured for {inflector.Pluralize(typeof(T).Name)}!", "Unconfigured");
+                }
+            }
+            catch (Exception e)
+            {
+                MessageWindow.ShowError("Import failed.", e);
+                refresh();
+            }
+        } while (retry);
+    }
+
     public static void CreateToolbarButtons<T>(IPanelHost host, Func<IDataModel<T>> model, Action refresh, Action? configurePost = null)
         where T : Entity, IPostable, IRemotable, IPersistent, new()
     {
+        if (!Security.CanPost<T>()) return;
+
         var postSettings = PosterUtils.LoadPostableSettings<T>();
-        if (Security.CanPost<T>() && !postSettings.PosterType.IsNullOrWhiteSpace())
+        if (!postSettings.PosterType.IsNullOrWhiteSpace())
         {
+            var posterEngine = PosterUtils.GetEngine(typeof(T));
+
             Bitmap? image = null;
             if (postSettings.Thumbnail.ID != Guid.Empty)
             {
@@ -170,6 +440,13 @@ public static class PostUtils
                         configurePost);
                 }
             });
+            if(posterEngine.Get(out var posterEngineType, out var _) && posterEngineType.HasInterface(typeof(IPullerEngine<>)))
+            {
+                host.CreatePanelAction(new PanelAction($"Import {inflector.Pluralize(typeof(T).Name)}", image ?? PRSDesktop.Resources.doc_xls, action =>
+                {
+                    PullEntities<T>(refresh);
+                }));
+            }
 
             if (postSettings.ShowClearButton)
             {
@@ -202,7 +479,7 @@ public static class PostUtils
             }
         }
 
-        if (configurePost is not null && Security.CanConfigurePost<T>())
+        if (configurePost is not null)
         {
             host.CreateSetupAction(new PanelAction
             {

+ 1 - 1
prs.shared/Posters/MYOB/BillMYOBPoster.cs

@@ -307,4 +307,4 @@ public class BillMYOBPoster : IMYOBPoster<Bill, BillMYOBPosterSettings>
         return results;
     }
 }
-public class BillMYOBPosterEngine<T> : MYOBPosterEngine<Bill, BillMYOBPosterSettings> { }
+public class BillMYOBPosterEngine<T> : MYOBPosterEngine<Bill, BillMYOBPoster, BillMYOBPosterSettings> { }

+ 156 - 4
prs.shared/Posters/MYOB/CustomerMYOBPoster.cs

@@ -19,6 +19,7 @@ using InABox.Clients;
 using InABox.Database;
 using MYOB.AccountRight.SDK;
 using InABox.Scripting;
+using Org.BouncyCastle.Bcpg.OpenPgp;
 
 namespace PRS.Shared.Posters.MYOB;
 
@@ -52,6 +53,18 @@ public static class ContactMYOBUtils
         lastName = names.Length > 1 ? names[1] : "";
     }
 
+    public static Address ConvertAddress(MYOBAddress address)
+    {
+        var newAddress = new Address
+        {
+            Street = address.Street,
+            City = address.City,
+            State = address.State,
+            PostCode = address.PostCode
+        };
+        return newAddress;
+    }
+
     public static MYOBAddress ConvertAddress(Address address, int location, IContact contact)
     {
         var mobile = contact.Mobile.Truncate(21);
@@ -119,7 +132,9 @@ public class CustomerMYOBAutoRefresher : IAutoRefresher<Customer>
 
 }
 
-public class CustomerMYOBPoster : IMYOBPoster<Customer, CustomerMYOBPosterSettings>, IAutoRefreshPoster<Customer, CustomerMYOBAutoRefresher>
+public class CustomerMYOBPoster :
+    IMYOBPoster<Customer, CustomerMYOBPosterSettings>,
+    IAutoRefreshPoster<Customer, CustomerMYOBAutoRefresher>
 {
     public ScriptDocument? Script { get; set; }
 
@@ -196,8 +211,8 @@ public class CustomerMYOBPoster : IMYOBPoster<Customer, CustomerMYOBPosterSettin
         myobCustomer.IsActive = customer.CustomerStatus.ID == Guid.Empty || customer.CustomerStatus.Active;
         myobCustomer.Addresses =
         [
-            ContactMYOBUtils.ConvertAddress(customer.Postal, 2, customer.DefaultContact),
-            ContactMYOBUtils.ConvertAddress(customer.Delivery, 1, customer.DefaultContact)
+            ContactMYOBUtils.ConvertAddress(customer.Postal, 1, customer.DefaultContact),
+            ContactMYOBUtils.ConvertAddress(customer.Delivery, 2, customer.DefaultContact)
         ];
         // Notes = 
         // PhotoURI =
@@ -278,6 +293,136 @@ public class CustomerMYOBPoster : IMYOBPoster<Customer, CustomerMYOBPosterSettin
         }).Flatten();
     }
 
+    public IPullResult<Customer> Pull()
+    {
+        var result = new PullResult<Customer>();
+
+        var top = 400;
+        var skip = 0;
+
+        var customerCodes = new HashSet<string>();
+
+        var service = new CustomerService(ConnectionData.Configuration, null, ConnectionData.AuthKey);
+        while (true)
+        {
+            if(!service.Query(ConnectionData, null, top: top, skip: skip).Get(out var myobCustomers, out var error))
+            {
+                CoreUtils.LogException("", error);
+                throw new PullFailedMessageException(error.Message);
+            }
+            if(myobCustomers.Items.Length == 0)
+            {
+                break;
+            }
+
+            var myobIDs = myobCustomers.Items.ToArray(x => x.UID.ToString());
+            var myobCodes = myobCustomers.Items.Select(x => x.DisplayID).Where(x => !x.IsNullOrWhiteSpace()).ToArray();
+            var myobNames = myobCustomers.Items.Where(x => x.DisplayID.IsNullOrWhiteSpace() && !x.CompanyName.IsNullOrWhiteSpace())
+                .Select(x => x.CompanyName).ToArray();
+
+            var customers = Client.Query(
+                new Filter<Customer>(x => x.PostedReference).InList(myobIDs)
+                    .Or(x => x.Code).InList(myobCodes)
+                    .Or(x => x.Name).InList(myobNames),
+                Columns.None<Customer>().Add(x => x.ID).Add(x => x.PostedReference).Add(x => x.Code).Add(x => x.Name))
+                .ToArray<Customer>();
+            var customerDict = customers.Where(x => !x.PostedReference.IsNullOrWhiteSpace())
+                .ToDictionary(x => x.PostedReference);
+
+            var needCodes = new Dictionary<string, (string prefix, int i, Customer customer)>();
+
+            foreach(var myobCustomer in myobCustomers.Items)
+            {
+                if (customerDict.TryGetValue(myobCustomer.UID.ToString(), out var customer))
+                {
+                    // Skipping existing customers at this point.
+                    continue;
+                }
+                customer = !myobCustomer.DisplayID.IsNullOrWhiteSpace()
+                    ? customers.FirstOrDefault(x => string.Equals(x.Code, myobCustomer.DisplayID))
+                    : customers.FirstOrDefault(x => string.Equals(x.Name, myobCustomer.CompanyName));
+                if(customer is not null)
+                {
+                    customer.PostedReference = myobCustomer.UID.ToString();
+                    result.AddEntity(PullResultType.Linked, customer);
+                    continue;
+                }
+
+                customer = new Customer();
+
+                string code;
+                if (!myobCustomer.DisplayID.IsNullOrWhiteSpace())
+                {
+                    code = myobCustomer.DisplayID.ToString();
+                }
+                else if (!myobCustomer.CompanyName.IsNullOrWhiteSpace())
+                {
+                    code = myobCustomer.CompanyName[..Math.Min(3, myobCustomer.CompanyName.Length)].ToUpper();
+                }
+                else
+                {
+                    code = "CUS";
+                }
+                int i = 1;
+                customer.Code = code;
+                while (customerCodes.Contains(customer.Code))
+                {
+                    customer.Code = $"{code}{i:d3}";
+                    ++i;
+                }
+                customerCodes.Add(customer.Code);
+
+                customer.Name = myobCustomer.CompanyName;
+                customer.ABN = myobCustomer.SellingDetails.ABN;
+
+                var delivery = myobCustomer.Addresses.FirstOrDefault(x => x.Location == 2);
+                if(delivery is not null)
+                {
+                    customer.Delivery.CopyFrom(ContactMYOBUtils.ConvertAddress(delivery));
+                }
+                var postal = myobCustomer.Addresses.FirstOrDefault(x => x.Location == 1);
+                if(postal is not null)
+                {
+                    customer.Postal.CopyFrom(ContactMYOBUtils.ConvertAddress(postal));
+                }
+                customer.Email = delivery?.Email ?? postal?.Email ?? "";
+
+                customer.PostedReference = myobCustomer.UID.ToString();
+                result.AddEntity(PullResultType.New, customer);
+                needCodes.Add(customer.Code, (code, i, customer));
+            }
+
+            // Do code clash checking
+            while(needCodes.Count > 0)
+            {
+                var codes = Client.Query(
+                    new Filter<Customer>(x => x.Code).InList(needCodes.Values.Select(x => x.customer.Code).ToArray()),
+                    Columns.None<Customer>().Add(x => x.Code));
+                var newNeedCodes = new Dictionary<string, (string prefix, int i, Customer customer)>();
+                foreach(var row in codes.Rows)
+                {
+                    var code = row.Get<Customer, string>(x => x.Code);
+                    if(needCodes.Remove(code, out var needed))
+                    {
+                        int i = needed.i;
+                        do
+                        {
+                            needed.customer.Code = $"{needed.prefix}{needed.i:d3}";
+                            ++i;
+                        } while (customerCodes.Contains(needed.customer.Code));
+                        customerCodes.Add(needed.customer.Code);
+                        newNeedCodes.Add(needed.customer.Code, (needed.prefix, i, needed.customer));
+                    }
+                }
+                needCodes = newNeedCodes;
+            }
+
+            skip += top;
+        }
+
+        return result;
+    }
+
     public IPostResult<Customer> Process(IDataModel<Customer> model)
     {
         var results = new PostResult<Customer>();
@@ -352,4 +497,11 @@ public class CustomerMYOBPoster : IMYOBPoster<Customer, CustomerMYOBPosterSettin
     }
 }
 
-public class CustomerMYOBPosterEngine<T> : MYOBPosterEngine<Customer, CustomerMYOBPosterSettings> { }
+public class CustomerMYOBPosterEngine<T> : MYOBPosterEngine<Customer, CustomerMYOBPoster, CustomerMYOBPosterSettings>, IPullerEngine<Customer, CustomerMYOBPoster>
+{
+    public IPullResult<Customer> DoPull()
+    {
+        LoadConnectionData();
+        return Poster.Pull();
+    }
+}

+ 1 - 1
prs.shared/Posters/MYOB/InvoiceMYOBPoster.cs

@@ -293,4 +293,4 @@ public class InvoiceMYOBPoster : IMYOBPoster<Invoice, InvoiceMYOBPosterSettings>
     }
 }
 
-public class InvoiceMYOBPosterEngine<T> : MYOBPosterEngine<Invoice, InvoiceMYOBPosterSettings> { }
+public class InvoiceMYOBPosterEngine<T> : MYOBPosterEngine<Invoice, InvoiceMYOBPoster, InvoiceMYOBPosterSettings> { }

+ 1 - 1
prs.shared/Posters/MYOB/ReceiptMYOBPoster.cs

@@ -248,4 +248,4 @@ public class ReceiptMYOBPoster : IMYOBPoster<Receipt, ReceiptMYOBPosterSettings>
     }
 }
 
-public class ReceiptMYOBPosterEngine<T> : MYOBPosterEngine<Receipt, ReceiptMYOBPosterSettings> { }
+public class ReceiptMYOBPosterEngine<T> : MYOBPosterEngine<Receipt, ReceiptMYOBPoster, ReceiptMYOBPosterSettings> { }

+ 1 - 1
prs.shared/Posters/MYOB/SupplierMYOBPoster.cs

@@ -340,4 +340,4 @@ public class SupplierMYOBPoster : IMYOBPoster<Supplier, SupplierMYOBPosterSettin
         return results;
     }
 }
-public class SupplierMYOBPosterEngine<T> : MYOBPosterEngine<Supplier, SupplierMYOBPosterSettings> { }
+public class SupplierMYOBPosterEngine<T> : MYOBPosterEngine<Supplier, SupplierMYOBPoster, SupplierMYOBPosterSettings> { }