diff --git a/DotNetShipping.Tests/Features/FedExShipRates.cs b/DotNetShipping.Tests/Features/FedExShipRates.cs index 7e464a3..855828e 100644 --- a/DotNetShipping.Tests/Features/FedExShipRates.cs +++ b/DotNetShipping.Tests/Features/FedExShipRates.cs @@ -36,9 +36,9 @@ public class FedExShipRates : FedExShipRatesTestsBase [Fact] public void FedExReturnsRates() { - var from = new Address("Annapolis", "MD", "21401", "US"); - var to = new Address("Fitchburg", "WI", "53711", "US"); - var package = new Package(7, 7, 7, 6, 0); + var from = new Address("", "", "60084", "US"); + var to = new Address("", "", "80465", "US") { IsResidential = true }; + var package = new Package(24, 24, 13, 50, 0); var r = _rateManager.GetRates(from, to, package); var fedExRates = r.Rates.ToList(); @@ -52,6 +52,51 @@ public void FedExReturnsRates() } } + [Fact] + public void FedExReturnsDifferentRatesForResidentialAndBusiness() + { + var from = new Address("Annapolis", "MD", "21401", "US"); + var residentialTo = new Address("Fitchburg", "WI", "53711", "US") { IsResidential = true }; + var businessTo = new Address("Fitchburg", "WI", "53711", "US") { IsResidential = false }; + var package = new Package(7, 7, 7, 6, 0); + + var residential = _rateManager.GetRates(from, residentialTo, package); + var residentialRates = residential.Rates.ToList(); + + var business = _rateManager.GetRates(from, businessTo, package); + var businessRates = business.Rates.ToList(); + + var homeFound = false; + var groundFound = false; + + // FedEx Home should come back for Residential + foreach (var rate in residentialRates) + { + if (rate.ProviderCode.Equals("GROUND_HOME_DELIVERY")) + homeFound = true; + if (rate.ProviderCode.Equals("FEDEX_GROUND")) + groundFound = true; + } + + Assert.True(homeFound); + Assert.True(!groundFound); + + homeFound = false; + groundFound = false; + + // FedEx Ground should come back for Business + foreach (var rate in businessRates) + { + if (rate.ProviderCode.Equals("GROUND_HOME_DELIVERY")) + homeFound = true; + if (rate.ProviderCode.Equals("FEDEX_GROUND")) + groundFound = true; + } + + Assert.True(!homeFound); + Assert.True(groundFound); + } + [Fact] public void FedExReturnsDifferentRatesForSignatureOnDelivery() { diff --git a/DotNetShipping.Tests/Features/USPSDomesticRates.cs b/DotNetShipping.Tests/Features/USPSDomesticRates.cs index 89fd45c..c5555b4 100644 --- a/DotNetShipping.Tests/Features/USPSDomesticRates.cs +++ b/DotNetShipping.Tests/Features/USPSDomesticRates.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Configuration; using System.Diagnostics; using System.Linq; @@ -170,5 +172,24 @@ public void Can_Get_Different_Rates_For_Signature_Required_Lookup() } } } + + [Fact] + public void USPS_Domestic_Will_Remove_Rates_That_Are_Not_Uniform_If_RequiredUniformRates_Enabled() + { + var rateManager = new RateManager(); + rateManager.AddProvider(new USPSProvider(USPSUserId, "ALL", String.Empty, true)); + + var package1 = new Package(14, 14, 6, 2, 0); + var package2 = new Package(16, 16, 48, 16, 0); + + var origin = new Address("", "", "21401", "US"); + var destination = new Address("", "", "54937", "US"); + + var response = rateManager.GetRates(origin, destination, new List() { package1, package2 } ); + + Assert.True(response.RatesExcluded); + Assert.NotNull(response.InfoMessages); + Assert.True(response.InfoMessages.Count > 0); + } } } \ No newline at end of file diff --git a/DotNetShipping.Tests/Features/USPSInternationalRates.cs b/DotNetShipping.Tests/Features/USPSInternationalRates.cs index 826ca17..7df1871 100644 --- a/DotNetShipping.Tests/Features/USPSInternationalRates.cs +++ b/DotNetShipping.Tests/Features/USPSInternationalRates.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Configuration; using System.Diagnostics; diff --git a/DotNetShipping/DotNetShipping.csproj b/DotNetShipping/DotNetShipping.csproj index 83eaa37..a6c4e66 100644 --- a/DotNetShipping/DotNetShipping.csproj +++ b/DotNetShipping/DotNetShipping.csproj @@ -66,8 +66,11 @@ + + + diff --git a/DotNetShipping/Helpers/USPSHelpers.cs b/DotNetShipping/Helpers/USPSHelpers.cs new file mode 100644 index 0000000..f39379a --- /dev/null +++ b/DotNetShipping/Helpers/USPSHelpers.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +using static System.String; + +namespace DotNetShipping.Helpers +{ + public static class USPSHelpers + { + /// + /// Removes encoded characters from mail service name for human consumption + /// + /// + /// + public static String SanitizeMailServiceName(this String mailServiceName) + { + if (!IsNullOrEmpty(mailServiceName)) + return Regex.Replace(mailServiceName, "<.*>", ""); + + return mailServiceName; + } + } +} diff --git a/DotNetShipping/InfoMessage.cs b/DotNetShipping/InfoMessage.cs new file mode 100644 index 0000000..f8bdba3 --- /dev/null +++ b/DotNetShipping/InfoMessage.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetShipping +{ + public class InfoMessage + { + /// + /// Shipping provider that generated the message + /// + public ShippingProvider ShippingProvider { get; set; } + + /// + /// Message + /// + public String Message { get; set; } + + public InfoMessage(ShippingProvider shippingProvider, String message) + { + ShippingProvider = shippingProvider; + Message = message; + } + } +} diff --git a/DotNetShipping/Shipment.cs b/DotNetShipping/Shipment.cs index bab69ae..f0a4a9a 100644 --- a/DotNetShipping/Shipment.cs +++ b/DotNetShipping/Shipment.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -13,9 +14,15 @@ public class Shipment public ICollection RateAdjusters; private readonly List _rates; private readonly List _serverErrors; + + /// + /// Will contain any informative messages + /// + private readonly List _infoMessages; + public readonly Address DestinationAddress; public readonly Address OriginAddress; - + public Shipment(Address originAddress, Address destinationAddress, List packages) { OriginAddress = originAddress; @@ -23,6 +30,7 @@ public Shipment(Address originAddress, Address destinationAddress, List Packages = packages.AsReadOnly(); _rates = new List(); _serverErrors = new List(); + _infoMessages = new List(); } public int PackageCount @@ -41,5 +49,14 @@ public List ServerErrors { get { return _serverErrors; } } + public List InfoMessages + { + get { return _infoMessages; } + } + + /// + /// If true, some rates were excluded. See InfoMessages for more information. + /// + public bool RatesExcluded { get; set; } } } diff --git a/DotNetShipping/ShippingProvider.cs b/DotNetShipping/ShippingProvider.cs new file mode 100644 index 0000000..380ab55 --- /dev/null +++ b/DotNetShipping/ShippingProvider.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DotNetShipping +{ + public enum ShippingProvider + { + UPS, + USPS, + USPSInternational, + FedEx, + FedExSmartPost + } +} diff --git a/DotNetShipping/ShippingProviders/USPSInternationalProvider.cs b/DotNetShipping/ShippingProviders/USPSInternationalProvider.cs index d2f3ce8..1b6b38a 100644 --- a/DotNetShipping/ShippingProviders/USPSInternationalProvider.cs +++ b/DotNetShipping/ShippingProviders/USPSInternationalProvider.cs @@ -10,6 +10,8 @@ using System.Xml; using System.Xml.Linq; +using DotNetShipping.Helpers; + namespace DotNetShipping.ShippingProviders { /// @@ -19,6 +21,16 @@ public class USPSInternationalProvider : AbstractShippingProvider private const string PRODUCTION_URL = "http://production.shippingapis.com/ShippingAPI.dll"; private readonly string _service; private readonly string _userId; + + /// + /// If set to true, the rates that are returned and combined across packages will only be done if even package has the same mail service available across returned packages. + /// + /// If 2 packages are calculated and the first package has rates for Priority Mail 2 Day and Standard Post, but the second package only supports Standard Post, then only Standard Post rates would be returned. + /// In instances where this happens, ignored shipping rates will be populated in the InfoMessages property of the Shipment. + /// + /// + private readonly bool _requireUniformMailServices; + private readonly Dictionary _serviceCodes = new Dictionary { {"Priority Mail Express International","Priority Mail Express International"}, @@ -76,6 +88,17 @@ public USPSInternationalProvider(string userId, string service) _service = service; } + /// + /// + /// + public USPSInternationalProvider(string userId, string service, bool requireUniformMailServices) + { + Name = "USPS"; + _userId = userId; + _service = service; + _requireUniformMailServices = requireUniformMailServices; + } + public bool Commercial { get; set; } /// @@ -183,8 +206,36 @@ public bool IsDomesticUSPSAvailable() private void ParseResult(string response) { var document = XDocument.Load(new StringReader(response)); + var excludedMailServices = new List(); + + var rates = document.Descendants("Service").GroupBy(item => (string) item.Element("SvcDescription")).Select(g => new {Name = g.Key, TotalCharges = g.Sum(x => Decimal.Parse((string) x.Element("Postage")))}).ToList(); - var rates = document.Descendants("Service").GroupBy(item => (string) item.Element("SvcDescription")).Select(g => new {Name = g.Key, TotalCharges = g.Sum(x => Decimal.Parse((string) x.Element("Postage")))}); + if (_requireUniformMailServices) + { + // Put together a list of excluded mail services by getting a count of packages and a count of each mail service returned + var totalPackages = document.Descendants("Package").Count(); + var mailServices = from item in document.Descendants("Postage") + group item by (string)item.Element("MailService") + into g + select new + { + Name = g.Key, + Count = g.Count() + }; + + excludedMailServices.AddRange(from mailService in mailServices where mailService.Count < totalPackages select mailService.Name); + } + + // Remove excluded rates + if (excludedMailServices.Count > 0) + { + rates.RemoveAll(x => excludedMailServices.Contains(x.Name)); + + var message = $"Removed {String.Join(", ", excludedMailServices.Select(x => x.SanitizeMailServiceName()))} from returned rates. Rate not available on all packages in Shipment."; + + Shipment.InfoMessages.Add(new InfoMessage(ShippingProvider.USPSInternational, message)); + Shipment.RatesExcluded = true; + } if (_service == "ALL") { diff --git a/DotNetShipping/ShippingProviders/USPSProvider.cs b/DotNetShipping/ShippingProviders/USPSProvider.cs index f21283f..dd09eed 100644 --- a/DotNetShipping/ShippingProviders/USPSProvider.cs +++ b/DotNetShipping/ShippingProviders/USPSProvider.cs @@ -10,6 +10,8 @@ using System.Xml.Linq; using System.Xml.XPath; +using DotNetShipping.Helpers; + namespace DotNetShipping.ShippingProviders { /// @@ -17,7 +19,6 @@ namespace DotNetShipping.ShippingProviders public class USPSProvider : AbstractShippingProvider { private const string PRODUCTION_URL = "http://production.shippingapis.com/ShippingAPI.dll"; - private const string REMOVE_FROM_RATE_NAME = "<sup>&reg;</sup>"; /// /// If set to ALL, special service types will not be returned. This is a limitation of the USPS API. @@ -27,6 +28,15 @@ public class USPSProvider : AbstractShippingProvider private readonly string _shipDate; private readonly string _userId; + /// + /// If set to true, the rates that are returned and combined across packages will only be done if even package has the same mail service available across returned packages. + /// + /// If 2 packages are calculated and the first package has rates for Priority Mail 2 Day and Standard Post, but the second package only supports Standard Post, then only Standard Post rates would be returned. + /// In instances where this happens, ignored shipping rates will be populated in the InfoMessages property of the Shipment. + /// + /// + private readonly bool _requireUniformMailServices; + /// /// Service codes. {0} is a placeholder for 1-Day, 2-Day, 3-Day, Military, DPO or a space /// @@ -84,7 +94,7 @@ public class USPSProvider : AbstractShippingProvider {"Priority Mail Express {0} Padded Flat Rate Envelope Hold For Pickup","Priority Mail Express {0} Padded Flat Rate Envelope Hold For Pickup"}, {"Priority Mail Express {0} Sunday/Holiday Delivery Padded Flat Rate Envelope","Priority Mail Express {0} Sunday/Holiday Delivery Padded Flat Rate Envelope"} }; - + public USPSProvider() { Name = "USPS"; @@ -120,6 +130,15 @@ public USPSProvider(string userId, string service, string shipDate) _shipDate = shipDate; } + public USPSProvider(string userId, string service, string shipDate, bool requireUniformMailServices) + { + Name = "USPS"; + _userId = userId; + _service = service; + _shipDate = shipDate; + _requireUniformMailServices = requireUniformMailServices; + } + /// /// Returns the supported service codes /// @@ -271,18 +290,46 @@ public bool IsPackageMachinable(Package package) return (package.Width <= 27 && package.Height <= 17 && package.Length <= 17) || (package.Width <= 17 && package.Height <= 27 && package.Length <= 17) || (package.Width <= 17 && package.Height <= 17 && package.Length <= 27); } - + private void ParseResult(string response, IList includeSpecialServiceCodes = null) { var document = XElement.Parse(response, LoadOptions.None); + var excludedMailServices = new List(); - var rates = from item in document.Descendants("Postage") + var rates = (from item in document.Descendants("Postage") group item by (string) item.Element("MailService") into g select new {Name = g.Key, TotalCharges = g.Sum(x => Decimal.Parse((string) x.Element("Rate"))), DeliveryDate = g.Select(x => (string) x.Element("CommitmentDate")).FirstOrDefault(), - SpecialServices = g.Select(x => x.Element("SpecialServices")).FirstOrDefault() }; + SpecialServices = g.Select(x => x.Element("SpecialServices")).FirstOrDefault() }).ToList(); + + if (_requireUniformMailServices) + { + // Put together a list of excluded mail services by getting a count of packages and a count of each mail service returned + var totalPackages = document.Descendants("Package").Count(); + var mailServices = from item in document.Descendants("Postage") + group item by (string)item.Element("MailService") + into g + select new + { + Name = g.Key, + Count = g.Count() + }; + + excludedMailServices.AddRange(from mailService in mailServices where mailService.Count < totalPackages select mailService.Name); + } + + // Remove excluded rates + if (excludedMailServices.Count > 0) + { + rates.RemoveAll(x => excludedMailServices.Contains(x.Name)); + + var message = $"Removed {String.Join(", ", excludedMailServices.Select(x => x.SanitizeMailServiceName()))} from returned rates. Rate not available on all packages in Shipment."; + + Shipment.InfoMessages.Add(new InfoMessage(ShippingProvider.USPS, message)); + Shipment.RatesExcluded = true; + } foreach (var r in rates) {