Forráskód Böngészése

Added customer Poster for Xero

Kenric Nugteren 6 napja
szülő
commit
9df717ff70

+ 1 - 1
prs.desktop/Utils/PostUtils.cs

@@ -451,7 +451,7 @@ public static class PostUtils
         if(window.ShowDialog() == true)
         {
             items = resultGrid.Selected.ToList();
-            Client.Save(items.Select(x => x.Item), "Posted by user.");
+            result.SavePull(items);
             return true;
         }
         else

+ 509 - 0
prs.shared/Posters/Xero/CustomerXeroPoster.cs

@@ -0,0 +1,509 @@
+using Comal.Classes;
+using InABox.Clients;
+using InABox.Core;
+using InABox.Poster.Xero;
+using InABox.Scripting;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using XeroCustomer = Xero.NetStandard.OAuth2.Model.Accounting.Contact;
+using XeroAccounting = Xero.NetStandard.OAuth2.Model.Accounting;
+using PRS.Shared.Posters.Xero.Services;
+using InABox.Core.Postable;
+using InABox.Database;
+
+namespace PRS.Shared.Posters.Xero;
+
+public class CustomerXeroPosterSettings : XeroPosterSettings
+{
+    protected override string DefaultScript()
+    {
+        return @"using XeroCustomer = Xero.NetStandard.OAuth2.Model.Accounting.Contact;
+
+// 'Module' *must* implement " + nameof(ICustomerXeroPosterScript) + @"
+public class Module : " + nameof(ICustomerXeroPosterScript) + @"
+{
+    public void BeforePost(IDataModel<Customer> model)
+    {
+        // Perform pre-processing
+    }
+
+    public void ProcessCustomer(IDataModel<Customer> model, Customer customer, XeroCustomer xeroCustomer)
+    {
+        // Do extra processing for a customer; throw an exception to fail this customer.
+    }
+}";
+    }
+}
+public interface ICustomerXeroPosterScript
+{
+    void BeforePost(IDataModel<Customer> model);
+
+    void ProcessCustomer(IDataModel<Customer> model, Customer customer, XeroCustomer xeroCustomer);
+}
+
+public class CustomerXeroAutoRefresher : IAutoRefresher<Customer>
+{
+    public bool ShouldRepost(Customer customer)
+    {
+        var shouldRepost = customer.HasOriginalValue(x => x.Name)
+            || customer.HasOriginalValue(x => x.Code)
+            || customer.HasOriginalValue(x => x.ABN)
+            || customer.HasOriginalValue(x => x.Email)
+            || customer.HasOriginalValue(x => x.DefaultContact.ID)
+            // || customer.HasOriginalValue(x => x.Telephone)
+            // || customer.Delivery.HasOriginalValue(x => x.Street)
+            // || customer.Delivery.HasOriginalValue(x => x.City)
+            // || customer.Delivery.HasOriginalValue(x => x.State)
+            // || customer.Delivery.HasOriginalValue(x => x.PostCode)
+            || customer.Postal.HasOriginalValue(x => x.Street)
+            || customer.Postal.HasOriginalValue(x => x.City)
+            || customer.Postal.HasOriginalValue(x => x.State)
+            || customer.Postal.HasOriginalValue(x => x.PostCode);
+        if (shouldRepost)
+        {
+            return true;
+        }
+
+        if(customer.CustomerStatus.HasOriginalValue(x => x.ID))
+        {
+            var originalID = customer.CustomerStatus.GetOriginalValue(x => x.ID);
+            var currentID = customer.CustomerStatus.ID;
+
+            var statuses = DbFactory.NewProvider(Logger.Main).Query(
+                Filter<CustomerStatus>.Where(x => x.ID).IsEqualTo(originalID).Or(x => x.ID).IsEqualTo(currentID),
+                Columns.None<CustomerStatus>().Add(x => x.ID).Add(x => x.Active))
+                .ToArray<CustomerStatus>();
+            if (statuses.Length == 2 && statuses[0].Active != statuses[1].Active)
+            {
+                return true;
+            }
+        }
+        return false;
+    }
+
+}
+public class CustomerXeroPoster : IXeroPoster<Customer, CustomerXeroPosterSettings>, IAutoRefreshPoster<Customer, CustomerXeroAutoRefresher>
+{
+    public ScriptDocument? Script { get; set; }
+    public XeroConnectionData ConnectionData { get; set; }
+    public CustomerXeroPosterSettings Settings { get; set; }
+    public IPosterDispatcher Dispatcher { get; set; }
+
+    public ICustomerXeroPosterScript? ScriptObject => Script?.GetObject<ICustomerXeroPosterScript>();
+
+    public bool BeforePost(IDataModel<Customer> model)
+    {
+        foreach (var (_, table) in model.ModelTables)
+        {
+            table.IsDefault = false;
+        }
+        model.SetIsDefault<Customer>(true);
+        model.SetColumns<Customer>(RequiredColumns());
+
+        model.SetIsDefault<CustomerContact>(true, alias: "Customer_CustomerContact");
+        model.SetColumns<CustomerContact>(RequiredCustomerContactColumns(), alias: "Customer_CustomerContact");
+
+        ScriptObject?.BeforePost(model);
+
+        return true;
+    }
+
+    #region Script Functions
+
+    private Result<Exception> ProcessCustomer(IDataModel<Customer> model, Customer customer, XeroCustomer xeroCustomer)
+    {
+        try
+        {
+            ScriptObject?.ProcessCustomer(model, customer, xeroCustomer);
+            return Result.Ok();
+        }
+        catch(Exception e)
+        {
+            return Result.Error(e);
+        }
+    }
+
+    #endregion
+
+    public static Columns<Customer> RequiredColumns()
+    {
+        return Columns.None<Customer>()
+            .Add(x => x.ID)
+            .Add(x => x.PostedReference)
+            .Add(x => x.PostedStatus)
+            .Add(x => x.DefaultContact.Name)
+            .Add(x => x.Name)
+            .Add(x => x.Code)
+            .Add(x => x.CustomerStatus.ID)
+            .Add(x => x.CustomerStatus.Active)
+            .Add(x => x.Postal.Street)
+            .Add(x => x.Postal.City)
+            .Add(x => x.Postal.State)
+            .Add(x => x.Postal.PostCode)
+            .Add(x => x.Email)
+            // .Add(x => x.Telephone)
+            .Add(x => x.ABN);
+    }
+
+    public static Columns<CustomerContact> RequiredCustomerContactColumns()
+    {
+        return Columns.None<CustomerContact>()
+            .Add(x => x.Customer.ID)
+            .Add(x => x.Contact.Name)
+            .Add(x => x.Contact.Email);
+    }
+
+    public static Result<Exception> UpdateCustomer(XeroConnectionData data, Customer customer, List<CustomerContact> contacts, XeroCustomer xeroCustomer)
+    {
+        // Documentation: https://developer.xero.com/documentation/api/accounting/contacts
+
+        // Since this might be called from some other poster, we need to ensure we have the right columns.
+        Client.EnsureColumns(customer, RequiredColumns());
+
+        ContactXeroUtils.SplitName(customer.DefaultContact.Name, out var firstName, out var lastName);
+
+        // CompanyNumber
+        // BankAccountDetails
+        // AccountsReceivableTaxType
+        // AccountsPayableTaxType
+        // DefaultCurrency
+        // XeroNetworkKey
+        // SalesDefaultAccountCode
+        // PurchasesDefaultAccountCode
+        // SalesTrackingCategories
+        // PurchasesTrackingCategories
+        // TrackingCategoryName
+        // TrackingOptionName
+        // PaymentTerms
+
+        xeroCustomer.Name = customer.Name.Replace("<", "").Replace(">", "").Trim().Truncate(255).Trim();
+        xeroCustomer.FirstName = firstName;
+        xeroCustomer.LastName = lastName;
+        xeroCustomer.ContactNumber = customer.Code.Truncate(50);
+        xeroCustomer.AccountNumber = xeroCustomer.AccountNumber.NotWhiteSpaceOr(xeroCustomer.ContactNumber);
+        xeroCustomer.ContactStatus = customer.CustomerStatus.ID == Guid.Empty || customer.CustomerStatus.Active
+            ? XeroCustomer.ContactStatusEnum.ACTIVE
+            : XeroCustomer.ContactStatusEnum.ARCHIVED;
+        xeroCustomer.EmailAddress = customer.Email.Truncate(255);
+        xeroCustomer.ContactPersons = contacts.Select(x =>
+        {
+            var person = new XeroAccounting.ContactPerson();
+            ContactXeroUtils.SplitName(x.Contact.Name, out var firstName, out var lastName);
+            person.FirstName = firstName;
+            person.LastName = lastName;
+            person.EmailAddress = x.Contact.Email;
+            return person;
+        }).Take(5).ToList();
+        xeroCustomer.TaxNumber = customer.ABN;
+
+        xeroCustomer.Addresses =
+        [
+            ContactXeroUtils.ConvertAddress(customer.Postal, XeroAccounting.Address.AddressTypeEnum.POBOX),
+            // Xero says that DELIVERY addresses are not valid for contacts
+            // ContactXeroUtils.ConvertAddress(customer.Delivery, XeroAccounting.Address.AddressTypeEnum.DELIVERY)
+        ];
+        // xeroCustomer.Phones =
+        // [
+        //     ContactXeroUtils.ConvertPhone(customer.Telephone, XeroAccounting.Phone.PhoneTypeEnum.OFFICE)
+        // ];
+
+        return Result.Ok();
+    }
+
+    /// <summary>
+    /// Try to find a customer in Xero which matches <paramref name="customer"/>, and if this fails, create a new one.
+    /// </summary>
+    /// <remarks>
+    /// After this has finished, <paramref name="customer"/> will be updated with <see cref="Customer.PostedReference"/> set to the correct ID.
+    /// <br/>
+    /// <paramref name="customer"/> needs to have at least <see cref="Customer.Code"/> and <see cref="Customer.PostedReference"/> as loaded columns.
+    /// </remarks>
+    /// <param name="data"></param>
+    /// <param name="customer">The customer to map to.</param>
+    /// <returns>The UID of the Xero customer.</returns>
+    public static Result<Guid, Exception> MapCustomer(XeroConnectionData data, Customer customer, List<CustomerContact> contacts)
+    {
+        if(Guid.TryParse(customer.PostedReference, out var myobID))
+        {
+            return Result.Ok(myobID);
+        }
+
+        var result = data.GetItem<ContactService, XeroCustomer>(Filter<XeroCustomer>.Where(x => x.ContactNumber).IsEqualTo(customer.Code));
+        return result.MapOk(xeroCustomer =>
+        {
+            if(xeroCustomer is null || !xeroCustomer.ContactID.HasValue)
+            {
+                if(customer.Code.Length > 50)
+                {
+                    return Result.Error(new Exception("Customer code is longer than 50 characters"));
+                }
+                xeroCustomer = new XeroCustomer();
+                return UpdateCustomer(data, customer, contacts, xeroCustomer)
+                    .MapOk(() => new ContactService(data).Save(xeroCustomer)
+                        .MapOk(x =>
+                        {
+                            customer.PostedReference = x.ContactID.ToString() ?? "";
+                            // Marking as repost because a script may not have run.
+                            customer.PostedStatus = PostedStatus.RequiresRepost;
+                            return x.ContactID ?? Guid.Empty;
+                        }))
+                    .Flatten();
+            }
+            else
+            {
+                customer.PostedReference = xeroCustomer.ContactID.ToString() ?? "";
+                customer.PostedStatus = PostedStatus.RequiresRepost;
+                return Result.Ok(xeroCustomer.ContactID.Value);
+            }
+        }).Flatten();
+    }
+
+    private static bool IsBlankCode(string code)
+    {
+        return code.IsNullOrWhiteSpace() || code.Equals("*None");
+    }
+
+    public IPullResult<Customer> Pull()
+    {
+        var result = new PullResult<Customer>();
+
+        var customerCodes = new HashSet<string>();
+
+        var page = 1;
+        var pageSize = 400;
+
+        var service = new ContactService(ConnectionData);
+        while (true)
+        {
+            if(!service.Query(null, page: page, pageSize: pageSize).Get(out var xeroCustomers, out var error))
+            {
+                CoreUtils.LogException("", error);
+                throw new PullFailedMessageException(error.Message);
+            }
+            if(xeroCustomers.Count == 0)
+            {
+                break;
+            }
+
+            var xeroIDs = xeroCustomers.ToArray(x => x.ContactID.ToString() ?? "");
+            var xeroCodes = xeroCustomers.Select(x => x.ContactNumber).Where(x => !IsBlankCode(x)).ToArray();
+            var xeroNames = xeroCustomers.Where(x => IsBlankCode(x.ContactNumber) && !x.Name.IsNullOrWhiteSpace())
+                .Select(x => x.Name).ToArray();
+
+            var customers = Client.Query(
+                Filter<Customer>.Where(x => x.PostedReference).InList(xeroIDs)
+                    .Or(x => x.Code).InList(xeroCodes)
+                    .Or(x => x.Name).InList(xeroNames),
+                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 blankCustomers = customers.Where(x => x.PostedReference.IsNullOrWhiteSpace()).ToArray();
+
+            var needCodes = new Dictionary<string, (string prefix, int i, Customer customer)>();
+
+            foreach(var xeroCustomer in xeroCustomers)
+            {
+                if (customerDict.TryGetValue(xeroCustomer.ContactID.ToString() ?? "", out var customer))
+                {
+                    // Skipping existing customers at this point.
+                    continue;
+                }
+                customer = !IsBlankCode(xeroCustomer.ContactNumber)
+                    ? blankCustomers.FirstOrDefault(x => string.Equals(x.Code, xeroCustomer.ContactNumber))
+                    : blankCustomers.FirstOrDefault(x => string.Equals(x.Name, xeroCustomer.Name));
+                if(customer is not null)
+                {
+                    customer.PostedReference = xeroCustomer.ContactID.ToString() ?? "";
+                    result.AddEntity(PullResultType.Linked, customer);
+                    continue;
+                }
+
+                customer = new Customer();
+
+                string code;
+                if (!IsBlankCode(xeroCustomer.ContactNumber))
+                {
+                    code = xeroCustomer.ContactID.ToString() ?? "";
+                }
+                else if (!xeroCustomer.Name.IsNullOrWhiteSpace())
+                {
+                    code = xeroCustomer.Name[..Math.Min(3, xeroCustomer.Name.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 = xeroCustomer.Name;
+                customer.ABN = xeroCustomer.TaxNumber;
+                customer.Email = xeroCustomer.EmailAddress;
+
+                if(xeroCustomer.Addresses is not null)
+                {
+                    var postal = xeroCustomer.Addresses.FirstOrDefault(x => x.AddressType == XeroAccounting.Address.AddressTypeEnum.POBOX);
+                    if(postal is not null)
+                    {
+                        customer.Postal.CopyFrom(ContactXeroUtils.ConvertAddress(postal));
+                    }
+                }
+
+                foreach(var contactPerson in xeroCustomer.ContactPersons)
+                {
+                    var contact = new Contact();
+                    contact.Name = $"{contactPerson.FirstName} {contactPerson.LastName}";
+                    contact.Email = contactPerson.EmailAddress;
+
+                    result.AddChildEntity(customer, contact);
+                    result
+                        .AddChildEntity(customer, new CustomerContact(),
+                            (p, c) => c.Customer.ID = p.ID)
+                        .AddParent(contact,
+                            (p, c) => c.Contact.ID = p.ID);
+                }
+
+                // customer.Telephone = xeroCustomer.Phones.FirstOrDefault()?.PhoneNumber ?? "";
+
+                customer.PostedReference = xeroCustomer.ContactID.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(
+                    Filter<Customer>.Where(x => x.Code).InList(needCodes.Values.ToArray(x => x.customer.Code)),
+                    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}{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;
+            }
+
+            ++page;
+        }
+
+        return result;
+    }
+
+    public IPostResult<Customer> Process(IDataModel<Customer> model)
+    {
+        var results = new PostResult<Customer>();
+
+        var service = new ContactService(ConnectionData);
+
+        var customers = model.GetTable<Customer>().ToArray<Customer>();
+        var contacts = model.GetTable<CustomerContact>(alias: "Customer_CustomerContact")
+            .ToObjects<CustomerContact>()
+            .GroupByDictionary(x => x.Customer.ID);
+        
+        foreach(var customer in customers)
+        {
+            if(customer.Code.Length > 50)
+            {
+                results.AddFailed(customer, "Code is longer than 50 characters.");
+                continue;
+            }
+            var customerContacts = contacts.GetValueOrDefault(customer.ID) ?? [];
+
+            XeroCustomer xeroCustomer;
+            Exception? error;
+            if(Guid.TryParse(customer.PostedReference, out var xeroID))
+            {
+                if(!service.Get(xeroID).Get(out var newCustomer, out error))
+                {
+                    CoreUtils.LogException("", error, $"Failed to find Customer in Xero with id {xeroID}");
+                    results.AddFailed(customer, $"Failed to find Customer in Xero with id {xeroID}: {error.Message}");
+                    continue;
+                }
+                xeroCustomer = newCustomer;
+            }
+            else
+            {
+                if (service.GetItem(Filter<XeroCustomer>.Where(x => x.ContactNumber).IsEqualTo(customer.Code))
+                    .Get(out var externalXeroCustomer, out error)
+                    && externalXeroCustomer is not null)
+                {
+                    xeroCustomer = externalXeroCustomer;
+                }
+                else if (service.GetItem(
+                    Filter<XeroCustomer>.Where(x => x.Name).IsEqualTo(customer.Name)
+                        .And(Filter<XeroCustomer>.Where(x => x.ContactNumber).IsEqualTo(null)
+                            .Or(x => x.ContactNumber).IsEqualTo("")
+                            .Or(x => x.ContactNumber).IsEqualTo("*None")))
+                    .Get(out externalXeroCustomer, out error)
+                    && externalXeroCustomer is not null)
+                {
+                    xeroCustomer = externalXeroCustomer;
+                    xeroCustomer.ContactNumber = customer.Code;
+                }
+                else if(error is null)
+                {
+                    xeroCustomer = new();
+                }
+                else
+                {
+                    CoreUtils.LogException("", error);
+                    results.AddFailed(customer, error.Message);
+                    continue;
+                }
+            }
+
+            if(UpdateCustomer(ConnectionData, customer, customerContacts, xeroCustomer)
+                .MapOk(() => ProcessCustomer(model, customer, xeroCustomer)).Flatten()
+                .MapOk(() => service.Save(xeroCustomer)).Flatten()
+                .Get(out var result, out error))
+            {
+                customer.PostedReference = result.ContactID.ToString() ?? "";
+                results.AddSuccess(customer);
+            }
+            else
+            {
+                CoreUtils.LogException("", error, $"Error while posting customer {customer.ID}");
+                results.AddFailed(customer, error.Message);
+            }
+        }
+
+        return results;
+    }
+}
+public class CustomerMYOBPosterEngine<T> : XeroPosterEngine<Customer, CustomerXeroPoster, CustomerXeroPosterSettings>, IPullerEngine<Customer, CustomerXeroPoster>
+{
+    public IPullResult<Customer> DoPull()
+    {
+        LoadConnectionData();
+        return Poster.Pull();
+    }
+
+    protected override IList<string> RequiredScopes()
+    {
+        return [
+            "accounting.contacts"
+            ];
+    }
+}

+ 10 - 3
prs.shared/Posters/Xero/SupplierXeroPoster.cs

@@ -222,9 +222,9 @@ public class SupplierXeroPoster : IXeroPoster<Supplier, SupplierXeroPosterSettin
     /// <returns>The UID of the Xero supplier.</returns>
     public static Result<Guid, Exception> MapSupplier(XeroConnectionData data, Supplier supplier, List<SupplierContact> contacts)
     {
-        if(Guid.TryParse(supplier.PostedReference, out var myobID))
+        if(Guid.TryParse(supplier.PostedReference, out var xeroID))
         {
-            return Result.Ok(myobID);
+            return Result.Ok(xeroID);
         }
 
         var result = data.GetItem<ContactService, XeroSupplier>(Filter<XeroSupplier>.Where(x => x.ContactNumber).IsEqualTo(supplier.Code));
@@ -360,6 +360,13 @@ public class SupplierXeroPoster : IXeroPoster<Supplier, SupplierXeroPosterSettin
                     var contact = new Contact();
                     contact.Name = $"{contactPerson.FirstName} {contactPerson.LastName}";
                     contact.Email = contactPerson.EmailAddress;
+
+                    result.AddChildEntity(supplier, contact);
+                    result
+                        .AddChildEntity(supplier, new SupplierContact(),
+                            (p, c) => c.Supplier.ID = p.ID)
+                        .AddParent(contact,
+                            (p, c) => c.Contact.ID = p.ID);
                 }
 
                 supplier.Telephone = xeroSupplier.Phones.FirstOrDefault()?.PhoneNumber ?? "";
@@ -481,7 +488,7 @@ public class SupplierXeroPoster : IXeroPoster<Supplier, SupplierXeroPosterSettin
         return results;
     }
 }
-public class SupplierMYOBPosterEngine<T> : XeroPosterEngine<Supplier, SupplierXeroPoster, SupplierXeroPosterSettings>, IPullerEngine<Supplier, SupplierXeroPoster>
+public class SupplierXeroPosterEngine<T> : XeroPosterEngine<Supplier, SupplierXeroPoster, SupplierXeroPosterSettings>, IPullerEngine<Supplier, SupplierXeroPoster>
 {
     public IPullResult<Supplier> DoPull()
     {