diff --git a/CatalogFeeds.sln b/CatalogFeeds.sln new file mode 100644 index 0000000..5216953 --- /dev/null +++ b/CatalogFeeds.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26730.15 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StefanOlsen.Commerce.CatalogFeed", "StefanOlsen.Commerce.CatalogFeed\StefanOlsen.Commerce.CatalogFeed.csproj", "{FD6E6A92-67A6-4B9B-B94F-03DE6CC237BB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StefanOlsen.Commerce.CatalogFeed.GoogleMerchant", "StefanOlsen.Commerce.CatalogFeed.GoogleMerchant\StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.csproj", "{BDF8F4E3-1584-4625-90CF-15DFCF0E0C67}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FD6E6A92-67A6-4B9B-B94F-03DE6CC237BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD6E6A92-67A6-4B9B-B94F-03DE6CC237BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD6E6A92-67A6-4B9B-B94F-03DE6CC237BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD6E6A92-67A6-4B9B-B94F-03DE6CC237BB}.Release|Any CPU.Build.0 = Release|Any CPU + {BDF8F4E3-1584-4625-90CF-15DFCF0E0C67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDF8F4E3-1584-4625-90CF-15DFCF0E0C67}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDF8F4E3-1584-4625-90CF-15DFCF0E0C67}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDF8F4E3-1584-4625-90CF-15DFCF0E0C67}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5BB33DC0-8D30-4583-832B-A422BFD2B97B} + EndGlobalSection +EndGlobal diff --git a/Samples/GoogleFeedMapping.xml b/Samples/GoogleFeedMapping.xml new file mode 100644 index 0000000..1d46970 --- /dev/null +++ b/Samples/GoogleFeedMapping.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/AdminPlugin/Controllers/GoogleProductFeedConfigController.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/AdminPlugin/Controllers/GoogleProductFeedConfigController.cs new file mode 100644 index 0000000..b746ab0 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/AdminPlugin/Controllers/GoogleProductFeedConfigController.cs @@ -0,0 +1,87 @@ +using System.Linq; +using System.Web.Mvc; +using EPiServer.Commerce.Security; +using EPiServer.PlugIn; +using Mediachase.Commerce.Markets; +using StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.AdminPlugin.ViewModels; +using StefanOlsen.Commerce.CatalogFeed.Mapping; +using StefanOlsen.Commerce.CatalogFeed.Settings; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.AdminPlugin.Controllers +{ + [GuiPlugIn( + Area = PlugInArea.AdminConfigMenu, + DisplayName = "Google Product Feed", + Url = "/googleproductfeedconfig")] + [Authorize(Roles = RoleNames.CommerceAdmins)] + public class GoogleProductFeedConfigController : Controller + { + private readonly CatalogService _catalogService; + private readonly IMarketService _marketService; + private readonly SettingsRepository _settingsRepository; + + public GoogleProductFeedConfigController( + CatalogService catalogService, + IMarketService marketService, + SettingsRepository settingsRepository) + { + _catalogService = catalogService; + _marketService = marketService; + _settingsRepository = settingsRepository; + } + + public ActionResult Index() + { + var viewModel = new AdministrationViewModel(); + viewModel.AvailableCatalogs = _catalogService.GetCatalogs().ToArray(); + viewModel.AvailableMarkets = _marketService.GetAllMarkets().ToArray(); + + FeedSettings feedSettings = + _settingsRepository.GetFeedSettings(Constants.ProviderNameGoogle) ?? new FeedSettings(); + + viewModel.CatalogIds = feedSettings.CatalogIdList; + viewModel.MarketIds = feedSettings.MarketIdList; + viewModel.Enabled = feedSettings.Enabled; + viewModel.Key = feedSettings.Key; + viewModel.FeedName = feedSettings.FeedName; + viewModel.FeedExpirationMinutes = feedSettings.FeedExpirationMinutes; + viewModel.MappingDocument = feedSettings.MappingDocument; + + return View("Index", viewModel); + } + + [HttpPost] + public ActionResult Index(AdministrationViewModel viewModel) + { + if (string.IsNullOrWhiteSpace(viewModel.MappingDocument)) + { + bool valid = FieldMappingHelper.ValidateFieldMapping(viewModel.MappingDocument); + if (!valid) + { + ModelState.AddModelError("MappingDocument", "The entered data is not valid XML."); + } + } + + if (!ModelState.IsValid) + { + return Index(); + } + + FeedSettings feedSettings = + _settingsRepository.GetFeedSettings(Constants.ProviderNameGoogle) ?? new FeedSettings(); + + feedSettings.CatalogIdList = viewModel.CatalogIds; + feedSettings.MarketIdList = viewModel.MarketIds; + feedSettings.ProviderName = Constants.ProviderNameGoogle; + feedSettings.Enabled = viewModel.Enabled; + feedSettings.Key = viewModel.Key; + feedSettings.FeedName = viewModel.FeedName; + feedSettings.FeedExpirationMinutes = viewModel.FeedExpirationMinutes; + feedSettings.MappingDocument = viewModel.MappingDocument; + + _settingsRepository.Save(feedSettings); + + return Index(); + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/AdminPlugin/ViewModels/AdministrationViewModel.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/AdminPlugin/ViewModels/AdministrationViewModel.cs new file mode 100644 index 0000000..eb284a8 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/AdminPlugin/ViewModels/AdministrationViewModel.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.Web.Mvc; +using EPiServer.Commerce.Catalog.ContentTypes; +using Mediachase.Commerce; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.AdminPlugin.ViewModels +{ + public class AdministrationViewModel + { + public CatalogContent[] AvailableCatalogs { get; set; } + + public IMarket[] AvailableMarkets { get; set; } + + public bool Enabled { get; set; } + + [Required] + public string FeedName { get; set; } + + [Required] + [StringLength(32, MinimumLength = 10)] + public string Key { get; set; } + + public int[] CatalogIds { get; set; } + + public string[] MarketIds { get; set; } + + [Required] + [Range(10, short.MaxValue)] + public int FeedExpirationMinutes { get; set; } + + [AllowHtml] + [Required] + public string MappingDocument { get; set; } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/CatalogFeedExportController.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/CatalogFeedExportController.cs new file mode 100644 index 0000000..7789ef7 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/CatalogFeedExportController.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using System.Web.Http; +using EPiServer.Framework.Blobs; +using StefanOlsen.Commerce.CatalogFeed.Data; +using StefanOlsen.Commerce.CatalogFeed.Settings; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant +{ + [RoutePrefix("catalogfeed/googlemerchant")] + public class GoogleMerchantCatalogFeedController : ApiController + { + private readonly IBlobFactory _blobFactory; + private readonly CatalogFeedDataService _catalogFeedDataService; + private readonly SettingsRepository _settingsRepository; + + + public GoogleMerchantCatalogFeedController( + IBlobFactory blobFactory, + CatalogFeedDataService catalogFeedDataService, + SettingsRepository settingsRepository) + { + _blobFactory = blobFactory; + _catalogFeedDataService = catalogFeedDataService; + _settingsRepository = settingsRepository; + } + + [Route("")] + public HttpResponseMessage GetFeed(string key, string marketId) + { + FeedSettings feedSettings = _settingsRepository.GetFeedSettings(Constants.ProviderNameGoogle); + if (feedSettings == null || + !feedSettings.Enabled) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + if (!string.Equals(key, feedSettings.Key, StringComparison.InvariantCulture)) + { + return new HttpResponseMessage(HttpStatusCode.Forbidden); + } + + CatalogFeedItem feedItem = _catalogFeedDataService.Get(Constants.ProviderNameGoogle, marketId); + if (feedItem == null) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + Blob blob = _blobFactory.GetBlob(feedItem.BlobId); + return new HttpResponseMessage + { + Content = new PushStreamContent(async (outputStream, httpContent, transportContext) => + await WriteToStream(outputStream, blob), + new MediaTypeHeaderValue("aplication/xml")) + }; + } + + private static async Task WriteToStream(Stream outputStream, Blob blob) + { + Stream blobStream = blob.OpenRead(); + try + { + await blobStream.CopyToAsync(outputStream); + } + finally + { + blobStream.Dispose(); + outputStream.Dispose(); + } + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/CatalogFeedInitialization.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/CatalogFeedInitialization.cs new file mode 100644 index 0000000..555a2c8 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/CatalogFeedInitialization.cs @@ -0,0 +1,28 @@ +using EPiServer.Framework; +using EPiServer.Framework.Initialization; +using EPiServer.ServiceLocation; +using StefanOlsen.Commerce.CatalogFeed.Data; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant +{ + [InitializableModule] + [ModuleDependency(typeof(ServiceContainerInitialization))] + public class CatalogFeedInitialization : IConfigurableModule + { + public void Initialize(InitializationEngine context) + { + } + + public void Uninitialize(InitializationEngine context) + { + } + + public void ConfigureContainer(ServiceConfigurationContext context) + { + IServiceConfigurationProvider services = context.Services; + + services.AddTransient(); + services.AddTransient(); + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Constants.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Constants.cs new file mode 100644 index 0000000..73b5aeb --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Constants.cs @@ -0,0 +1,17 @@ +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant +{ + internal static class Constants + { + public const string NamespaceAtom = "http://www.w3.org/2005/Atom"; + public const string NamespaceGoogleMerchant = "http://base.google.com/ns/1.0"; + + public const string BooleanValueNo = "no"; + public const string BooleanValueYes = "yes"; + + public const string InventoryInStock = "in stock"; + public const string InventoryOutOfStock = "out of stock"; + public const string InventoryPreorder = "preorder"; + + public const string ProviderNameGoogle = "GoogleProductFeed"; + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/GoogleCatalogFeedJob.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/GoogleCatalogFeedJob.cs new file mode 100644 index 0000000..b3d3b09 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/GoogleCatalogFeedJob.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Linq; +using EPiServer.Framework.Blobs; +using EPiServer.PlugIn; +using EPiServer.Scheduler; +using Mediachase.Commerce; +using Mediachase.Commerce.Markets; +using StefanOlsen.Commerce.CatalogFeed.Data; +using StefanOlsen.Commerce.CatalogFeed.Mapping; +using StefanOlsen.Commerce.CatalogFeed.Settings; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant +{ + [ScheduledPlugIn(DisplayName = "Google Catalog Feed")] + public class GoogleCatalogFeedJob : ScheduledJobBase + { + private static readonly Guid BlobContainerId = Guid.Parse("C01CD7D6-67A2-489B-AEFB-BC28EC583E73"); + private readonly GoogleCatalogFeedService _catalogFeedService; + private readonly IBlobFactory _blobFactory; + private readonly IMarketService _marketService; + private readonly ICatalogFeedDataService _catalogFeedDataService; + private readonly SettingsRepository _settingsRepository; + + public GoogleCatalogFeedJob( + GoogleCatalogFeedService catalogFeedService, + IBlobFactory blobFactory, + IMarketService marketService, + ICatalogFeedDataService catalogFeedDataService, + SettingsRepository settingsRepository) + { + _catalogFeedService = catalogFeedService; + _blobFactory = blobFactory; + _marketService = marketService; + _catalogFeedDataService = catalogFeedDataService; + _settingsRepository = settingsRepository; + } + + public override string Execute() + { + FeedSettings feedSettings = _settingsRepository.GetFeedSettings(Constants.ProviderNameGoogle); + if (!CanExecute(feedSettings)) + { + return "This catalog feed is not enabled or not completely set up. Exiting."; + } + + FieldMapping fieldMapping = FieldMappingHelper.LoadFieldMapping(feedSettings.MappingDocument); + + foreach (var marketId in feedSettings.MarketIdList) + { + IMarket market = _marketService.GetMarket(new MarketId(marketId)); + + Uri containerIdentifier = Blob.GetContainerIdentifier(BlobContainerId); + Blob blob = _blobFactory.CreateBlob(containerIdentifier, ".xml"); + + using (Stream blobStream = blob.OpenWrite()) + { + OnStatusChanged($"Creating catalog feed for market {market.MarketId}."); + + _catalogFeedService.GetCatalogFeed(feedSettings.CatalogIdList, market, feedSettings.FeedName, fieldMapping, blobStream); + + OnStatusChanged("Catalog feed created."); + } + + _catalogFeedDataService.Create(blob.ID, Constants.ProviderNameGoogle, market.MarketId.Value); + } + + //OnStatusChanged(""); + + CleanFeedItems(); + + return "Sucessfully created catalog feeds."; + } + + private bool CanExecute(FeedSettings feedSettings) + { + return feedSettings != null && + feedSettings.Enabled && + feedSettings.CatalogIds.Any() && + feedSettings.MarketIdList.Any(); + } + + private void CleanFeedItems() + { + int count = 0; + + var feedItems = _catalogFeedDataService.GetAll(); + foreach (var feedItem in feedItems) + { + if (feedItem.ExpireDate > DateTime.UtcNow) + { + continue; + } + + OnStatusChanged($"Deleting expired blob ({feedItem.BlobId})."); + + _blobFactory.Delete(feedItem.BlobId); + _catalogFeedDataService.Delete(feedItem.Id); + } + + OnStatusChanged($"Deleted {count} expired feed blobs."); + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/GoogleCatalogFeedService.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/GoogleCatalogFeedService.cs new file mode 100644 index 0000000..102d6fe --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/GoogleCatalogFeedService.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Serialization; +using EPiServer; +using EPiServer.Commerce.Catalog.ContentTypes; +using EPiServer.Commerce.SpecializedProperties; +using EPiServer.Core; +using EPiServer.ServiceLocation; +using EPiServer.Web; +using EPiServer.Web.Routing; +using Mediachase.Commerce; +using Mediachase.Commerce.Markets; +using StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.Models; +using StefanOlsen.Commerce.CatalogFeed.Mapping; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant +{ + [ServiceConfiguration(typeof(GoogleCatalogFeedService))] + public class GoogleCatalogFeedService : ICatalogFeedService + { + private readonly CatalogService _catalogService; + private readonly IMarketService _marketService; + private readonly IProductDataService _productDataService; + private readonly ProductMetadataMapper _productMetadataMapper; + private readonly UrlResolver _urlResolver; + private FieldMapping _fieldMapping; + + public GoogleCatalogFeedService( + CatalogService catalogService, + IMarketService marketService, + IProductDataService productDataService, + ProductMetadataMapper productMetadataMapper, + UrlResolver urlResolver) + { + _catalogService = catalogService; + _marketService = marketService; + _productDataService = productDataService; + _productMetadataMapper = productMetadataMapper; + _urlResolver = urlResolver; + } + + public void GetCatalogFeed(int[] catalogIds, IMarket market, string feedName, FieldMapping fieldMapping, Stream outputStream) + { + _fieldMapping = fieldMapping; + + var feed = new Feed(); + feed.Title = feedName; + feed.Updated = DateTime.UtcNow; + + IEnumerable catalogs = _catalogService.GetCatalogs(catalogIds); + IEnumerable entries = GetEntries(catalogs); + feed.Entries = new Enumerable(entries); + + Serialize(feed, outputStream); + } + + protected IEnumerable GetEntries(IEnumerable catalogContents) + { + return catalogContents.SelectMany(GetEntries); + } + + protected IEnumerable GetEntries(CatalogContent catalogContent) + { + var defaultCulture = CultureInfo.GetCultureInfo(catalogContent.DefaultLanguage); + + var products = _catalogService.GetTreeEntries(catalogContent.ContentLink, defaultCulture); + var productEntries = products.SelectMany(p => GetEntries(p, defaultCulture)) + .Where(e => e != null); + + return productEntries; + } + + protected IEnumerable GetEntries(ProductContent productContent, CultureInfo defaultCulture) + { + var catalogNode = _catalogService.GetParentCatalogNode(productContent); + var variations = _catalogService.GetVariations(productContent).ToList(); + + IMarket market = _marketService.GetAllMarkets().FirstOrDefault(); + Dictionary itemPrices = _productDataService + .GetPrices(variations, market, DateTime.Now) + .ToDictionary(ip => ip.Code, ip => ip); + + foreach (var variation in variations) + { + if (!itemPrices.TryGetValue(variation.Code, out ItemPrice itemPrice)) + { + continue; + } + + Entry entry = GetEntry(catalogNode, productContent, variation, itemPrice, defaultCulture); + + yield return entry; + } + } + + protected Entry GetEntry( + NodeContent nodeContent, + ProductContent productContent, + VariationContent variationContent, + ItemPrice itemPrice, + CultureInfo defaultCulture) + { + var entry = new Entry(); + entry.Id = variationContent.Code; + entry.Price = $"{itemPrice.UnitPrice:F2} {itemPrice.Currency}"; + entry.SalePrice = $"{itemPrice.SalePrice:F2} {itemPrice.Currency}"; + + var nodeMapping = + _fieldMapping.ContentType.FirstOrDefault( + x => x.CommerceType == simpleTypeCommerceEntityType.CatalogNode); + var productMapping = + _fieldMapping.ContentType.FirstOrDefault(x => x.CommerceType == simpleTypeCommerceEntityType.Product); + var variationMapping = + _fieldMapping.ContentType.FirstOrDefault(x => x.CommerceType == simpleTypeCommerceEntityType.Variation); + + AddLinkUrl(entry, productContent); + AddImageUrls(entry, productContent, productMapping?.ImageGroup?.AssetMetaField); + + _productMetadataMapper.SetEntryProperties(entry, nodeContent, nodeMapping?.Fields); + _productMetadataMapper.SetEntryProperties(entry, productContent, productMapping?.Fields); + _productMetadataMapper.SetEntryProperties(entry, variationContent, variationMapping?.Fields); + + ItemInventory itemInventory = _productDataService.GetInventory(variationContent); + if (itemInventory == null || + itemInventory.AvailableQuantity == 0 && + itemInventory.PreorderQuantity == 0) + { + entry.Availablity = Constants.InventoryOutOfStock; + } + else if (itemInventory.AvailableQuantity > 0) + { + entry.Availablity = Constants.InventoryInStock; + } + else if (itemInventory.PreorderQuantity > 0) + { + entry.Availablity = Constants.InventoryPreorder; + } + + return entry; + } + + private void AddLinkUrl(Entry entry, CatalogContentBase entryContent) + { + string url = GetUrl(entryContent.ContentLink, entryContent.Language); + + entry.Link = url; + } + + private void AddImageUrls(Entry entry, ProductContent productContent, string groupName) + { + IEnumerable mediaItems = productContent.CommerceMediaCollection; + mediaItems = mediaItems + .OrderBy(mi => mi.SortOrder) + .Where(mi => + string.IsNullOrWhiteSpace(groupName) || + string.Equals(mi.GroupName, groupName, StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); + + if (!mediaItems.Any()) + { + return; + } + + string[] imageUrls = mediaItems.Select(mi => GetUrl(mi.AssetLink, null)).ToArray(); + entry.ImageLink = imageUrls.First(); + + entry.AdditionalImageLinks = imageUrls.Skip(1).ToArray(); + } + + private string GetUrl(ContentReference contentlink, CultureInfo language) + { + string url = _urlResolver.GetUrl( + contentlink, + language?.Name, + new VirtualPathArguments + { + ContextMode = ContextMode.Default, + ValidateTemplate = false + }); + + url = UriSupport.AbsoluteUrlBySettings(url); + + return url; + } + + private void Serialize(Feed feed, Stream outputStream) + { + var namespaces = new XmlSerializerNamespaces(); + namespaces.Add("", Constants.NamespaceAtom); + namespaces.Add("g", Constants.NamespaceGoogleMerchant); + + var writerSettings = new XmlWriterSettings + { + Encoding = Encoding.UTF8, + OmitXmlDeclaration = false, +#if DEBUG + Indent = true +#endif + }; + using (StreamWriter streamWriter = new StreamWriter(outputStream, writerSettings.Encoding, 4096, true)) + //using (TextWriter stringWriter = new StringWriter()) + using (XmlWriter xmlWriter = XmlWriter.Create(streamWriter, writerSettings)) + { + var serializer = new XmlSerializer(typeof(Feed)); + serializer.Serialize(xmlWriter, feed, namespaces); + } + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Entry.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Entry.cs new file mode 100644 index 0000000..02fb9ec --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Entry.cs @@ -0,0 +1,121 @@ +using System; +using System.Xml.Serialization; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.Models +{ + [Serializable] + [XmlType("entry", Namespace = Constants.NamespaceGoogleMerchant)] + public class Entry + { + #region Basic product data + [XmlElement("id", Namespace = Constants.NamespaceGoogleMerchant)] + public string Id { get; set; } + + [XmlElement("title", Namespace = Constants.NamespaceGoogleMerchant)] + public string Title { get; set; } + + [XmlElement("description", Namespace = Constants.NamespaceGoogleMerchant)] + public string Description { get; set; } + + [XmlElement("link", Namespace = Constants.NamespaceGoogleMerchant)] + public string Link { get; set; } + + [XmlElement("image_link", Namespace = Constants.NamespaceGoogleMerchant)] + public string ImageLink { get; set; } + + [XmlElement("additional_image_link")] + public string[] AdditionalImageLinks { get; set; } + #endregion + + #region Price & Availability + [XmlElement("availability", Namespace = Constants.NamespaceGoogleMerchant)] + public string Availablity { get; set; } + + [XmlElement("availability_date", Namespace = Constants.NamespaceGoogleMerchant)] + public DateTime AvailabilityDate { get; set; } + + [XmlIgnore] + public bool AvailabilityDateSpecified { get; set; } + + [XmlElement("expiration_date", Namespace = Constants.NamespaceGoogleMerchant)] + public DateTime ExpirationDate { get; set; } + + [XmlIgnore] + public bool ExpirationDateSpecified { get; set; } + + [XmlElement("price", Namespace = Constants.NamespaceGoogleMerchant)] + public string Price { get; set; } + + [XmlElement("sale_price", Namespace = Constants.NamespaceGoogleMerchant)] + public string SalePrice { get; set; } + + [XmlElement("shipping", Namespace = Constants.NamespaceGoogleMerchant)] + public Shipping[] Shipping { get; set; } + + #endregion + + #region Product category + [XmlElement("google_product_category", Namespace = Constants.NamespaceGoogleMerchant)] + public int GoogleProductCategory { get; set; } + + [XmlIgnore] + public bool GoogleProductCategorySpecified { get; set; } + + [XmlElement("product_type", Namespace = Constants.NamespaceGoogleMerchant)] + public string ProductType { get; set; } + #endregion + + #region Product IDs + [XmlElement("brand", Namespace = Constants.NamespaceGoogleMerchant)] + public string Brand { get; set; } + + [XmlElement("gtin", Namespace = Constants.NamespaceGoogleMerchant)] + public string Gtin { get; set; } + + [XmlElement("mpn", Namespace = Constants.NamespaceGoogleMerchant)] + public string MPN { get; set; } + + [XmlElement("identifier_exists", Namespace = Constants.NamespaceGoogleMerchant)] + public string IdentifierExists => + !string.IsNullOrWhiteSpace(Brand) && + (!string.IsNullOrWhiteSpace(Gtin) || !string.IsNullOrWhiteSpace(MPN)) + ? Constants.BooleanValueYes + : Constants.BooleanValueNo; + + [XmlIgnore] + public bool IdentifierExistsSpecified => IdentifierExists == Constants.BooleanValueNo; + #endregion + + #region Detailed product description + [XmlElement("condition", Namespace = Constants.NamespaceGoogleMerchant)] + public string Condition { get; set; } + + [XmlElement("adult", Namespace = Constants.NamespaceGoogleMerchant)] + public bool IsAdult { get; set; } + + [XmlIgnore] + public bool IsAdultSpecified { get; set; } + + [XmlElement("age_group", Namespace = Constants.NamespaceGoogleMerchant)] + public string AgeGroup { get; set; } + + [XmlElement("color", Namespace = Constants.NamespaceGoogleMerchant)] + public string Color { get; set; } + + [XmlElement("gender", Namespace = Constants.NamespaceGoogleMerchant)] + public string Gender { get; set; } + + [XmlElement("size", Namespace = Constants.NamespaceGoogleMerchant)] + public string Size { get; set; } + + [XmlElement("size_system", Namespace = Constants.NamespaceGoogleMerchant)] + public string SizeSystem { get; set; } + + [XmlElement("is_bundle", Namespace = Constants.NamespaceGoogleMerchant)] + public bool IsBundle { get; set; } + + [XmlIgnore] + public bool IsBundleSpecified { get; set; } + #endregion + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Feed.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Feed.cs new file mode 100644 index 0000000..394fe53 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Feed.cs @@ -0,0 +1,22 @@ +using System; +using System.Xml.Serialization; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.Models +{ + [Serializable] + [XmlRoot("feed", Namespace = Constants.NamespaceAtom)] + public class Feed + { + [XmlElement("title", Namespace = Constants.NamespaceAtom)] + public string Title { get; set; } + + [XmlElement("link", Namespace = Constants.NamespaceAtom)] + public string Link { get; set; } + + [XmlElement("updated", Namespace = Constants.NamespaceAtom)] + public DateTime Updated { get; set; } + + [XmlElement("entry")] + public Enumerable Entries { get; set; } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Shipping.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Shipping.cs new file mode 100644 index 0000000..d65bd0b --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Models/Shipping.cs @@ -0,0 +1,19 @@ +using System; +using System.Xml.Serialization; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.Models +{ + [Serializable] + [XmlType("shipping", Namespace = Constants.NamespaceGoogleMerchant)] + public class Shipping + { + [XmlElement("country", Namespace = Constants.NamespaceGoogleMerchant)] + public string Country { get; set; } + + [XmlElement("service", Namespace = Constants.NamespaceGoogleMerchant)] + public string Service { get; set; } + + [XmlElement("price", Namespace = Constants.NamespaceGoogleMerchant)] + public string Price { get; set; } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/ProductMetadataMapper.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/ProductMetadataMapper.cs new file mode 100644 index 0000000..342aa06 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/ProductMetadataMapper.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using EPiServer.Commerce.Catalog.ContentTypes; +using EPiServer.Core; +using EPiServer.HtmlParsing; +using EPiServer.Security; +using Mediachase.MetaDataPlus; +using Mediachase.MetaDataPlus.Configurator; +using StefanOlsen.Commerce.CatalogFeed.Mapping; +using Entry = StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.Models.Entry; + +namespace StefanOlsen.Commerce.CatalogFeed.GoogleMerchant +{ + public class ProductMetadataMapper + { + private readonly Dictionary _metaClassFields; + + public ProductMetadataMapper() + { + _metaClassFields = new Dictionary(); + } + + public void SetEntryProperties(Entry entry, TContent entryContent, ICollection fieldMappings) + where TContent : ContentData, IMetaClass, ILocale + { + if (entry == null || + entryContent == null || + fieldMappings== null || + !fieldMappings.Any()) + { + return; + } + + MetaField[] metaFields = GetMetaFields(entryContent, entryContent.Language).ToArray(); + + foreach (var fieldMapping in fieldMappings) + { + object fieldData; + if (fieldMapping is complexTypeFixedFieldType) + { + var fixedFieldMapping = fieldMapping as complexTypeFixedFieldType; + fieldData = fixedFieldMapping.Value; + + SetEntryProperties(entry, fieldData, fixedFieldMapping); + } + else if (fieldMapping is complexTypeMappedFieldType) + { + var mappedFieldMapping = fieldMapping as complexTypeMappedFieldType; + + MetaField metaField = metaFields + .FirstOrDefault(mf => mf.Name == mappedFieldMapping.MetaField); + if (metaField == null) + { + continue; + } + + fieldData = entryContent[mappedFieldMapping.MetaField]; + + SetEntryProperties(entry, fieldData, mappedFieldMapping); + } + } + } + + private void SetEntryProperties(Entry entry, object fieldData, complexTypeBaseFieldType fieldMapping) + { + switch (fieldMapping.FeedField) + { + case "id": + entry.Id = GetStringValue(fieldData); + break; + case "title": + entry.Title = GetStringValue(fieldData); + break; + case "description": + entry.Description = GetStringValue(fieldData); + break; + + case "availability_date": + entry.AvailabilityDate = GetDateTimeValue(fieldData); + entry.AvailabilityDateSpecified = fieldData is DateTime; + break; + case "expiration_date": + entry.ExpirationDate = GetDateTimeValue(fieldData); + entry.ExpirationDateSpecified = fieldData is DateTime; + break; + + case "google_product_category": + entry.GoogleProductCategory = GetIntValue(fieldData); + entry.GoogleProductCategorySpecified = fieldData is int; + break; + case "product_type": + entry.ProductType = GetStringValue(fieldData); + break; + + case "brand": + entry.Brand = GetStringValue(fieldData); + break; + case "gtin": + entry.Gtin = GetStringValue(fieldData); + break; + case "mpn": + entry.MPN = GetStringValue(fieldData); + break; + + case "condition": + entry.Condition = GetStringValue(fieldData); + break; + case "is_adult": + entry.IsAdult = GetBooleanValue(fieldData); + entry.IsAdultSpecified = fieldData is bool; + break; + case "age_group": + entry.AgeGroup = GetStringValue(fieldData); + break; + case "color": + entry.Color = GetStringValue(fieldData); + break; + case "gender": + entry.Gender = GetStringValue(fieldData); + break; + case "size": + entry.Size = GetStringValue(fieldData); + break; + case "size_system": + entry.Size = GetStringValue(fieldData); + break; + + case "link": + case "image_link": + case "additional_image_link": + case "availabiity": + case "price": + case "sale_price": + case "is_bundle": + // Do nothing. These should be calculated by code. + break; + } + } + + private static DateTime GetDateTimeValue(object fieldData) + { + if (fieldData == null) + { + return default(DateTime); + } + + return (DateTime)fieldData; + } + + private static bool GetBooleanValue(object fieldData) + { + if (fieldData == null) + { + return false; + } + + return (bool)fieldData; + } + + private static int GetIntValue(object fieldData) + { + if (fieldData == null) + { + return default(int); + } + + return (int) fieldData; + } + + private static string GetStringValue(object fieldData) + { + if (fieldData is XhtmlString xhtmlString) + { + string htmlString = xhtmlString.ToHtmlString(PrincipalInfo.AnonymousPrincipal); + string textString = StripHtml(htmlString); + + return textString; + } + + return (string)fieldData; + } + + private static string StripHtml(string text) + { + IEnumerable fragments = new HtmlStreamReader(text, ParserOptions.None); + + string result = fragments + .Where(f => f.FragmentType == HtmlFragmentType.Text) + .Aggregate(string.Empty, (current, source) => current + source); + + return result; + } + + private IEnumerable GetMetaFields(IMetaClass entryContent, CultureInfo language) + { + int metaClassId = entryContent.MetaClassId; + if (_metaClassFields.TryGetValue(metaClassId, out var metaFields)) + { + return metaFields; + } + + var metaClassContext = new MetaDataContext + { + UseCurrentThreadCulture = false, + Language = language.Name + }; + + var metaClass = MetaClass.Load(metaClassContext, metaClassId); + metaFields = metaClass.GetAllMetaFields().ToArray(); + + _metaClassFields.Add(metaClassId, metaFields); + + return metaFields; + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Properties/AssemblyInfo.cs b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..706fb01 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("StefanOlsen.Commerce.CatalogFeed.GoogleMerchant")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("StefanOlsen.Commerce.CatalogFeed.GoogleMerchant")] +[assembly: AssemblyCopyright("Copyright © 2017")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("bdf8f4e3-1584-4625-90cf-15dfcf0e0c67")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.csproj b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.csproj new file mode 100644 index 0000000..6f9c18e --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.csproj @@ -0,0 +1,229 @@ + + + + + Debug + AnyCPU + {BDF8F4E3-1584-4625-90CF-15DFCF0E0C67} + Library + Properties + StefanOlsen.Commerce.CatalogFeed.GoogleMerchant + StefanOlsen.Commerce.CatalogFeed.GoogleMerchant + v4.6.2 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\AjaxControlToolkit.dll + + + ..\packages\AuthorizeNet.1.9.2\lib\AuthorizeNet.dll + + + ..\packages\Castle.Core.3.3.3\lib\net45\Castle.Core.dll + + + ..\packages\Castle.Windsor.3.3.0\lib\net45\Castle.Windsor.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.ApplicationModules.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\EPiServer.Business.Commerce.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\EPiServer.Commerce.Internal.Migration.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.Configuration.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Data.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Data.Cache.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.Enterprise.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Events.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Framework.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.ImageLibrary.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Licensing.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.LinkAnalyzer.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.Web.WebControls.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.XForms.dll + + + ..\packages\SharpZipLib.0.86.0\lib\20\ICSharpCode.SharpZipLib.dll + + + ..\packages\Lucene.Net.3.0.3\lib\NET40\Lucene.Net.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.BusinessFoundation.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.BusinessFoundation.Data.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Marketing.Validators.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Plugins.Payment.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Plugins.Shipping.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Website.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Workflow.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.DataProvider.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.FileUploader.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.MetaDataPlus.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Search.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Search.Extensions.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Search.LuceneSearchProvider.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.SqlDataProvider.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.WebConsoleLib.dll + + + ..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll + + + ..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll + + + ..\packages\structuremap-signed.3.1.9.463\lib\net40\StructureMap.dll + + + ..\packages\structuremap-signed.3.1.9.463\lib\net40\StructureMap.Net4.dll + + + ..\packages\structuremap.web-signed.3.1.6.186\lib\net40\StructureMap.Web.dll + + + + + + ..\packages\Microsoft.AspNet.WebApi.Client.5.2.3\lib\net45\System.Net.Http.Formatting.dll + + + + ..\packages\Microsoft.Tpl.Dataflow.4.5.24\lib\portable-net45+win8+wpa81\System.Threading.Tasks.Dataflow.dll + True + + + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.Helpers.dll + + + ..\packages\Microsoft.AspNet.WebApi.Core.5.2.3\lib\net45\System.Web.Http.dll + + + ..\packages\Microsoft.AspNet.WebApi.WebHost.5.2.3\lib\net45\System.Web.Http.WebHost.dll + + + ..\packages\Microsoft.AspNet.Mvc.5.2.3\lib\net45\System.Web.Mvc.dll + + + ..\packages\Microsoft.AspNet.Razor.3.2.3\lib\net45\System.Web.Razor.dll + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.dll + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Deployment.dll + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Razor.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {fd6e6a92-67a6-4b9b-b94f-03de6cc237bb} + StefanOlsen.Commerce.CatalogFeed + + + + \ No newline at end of file diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Views/GoogleProductFeedConfig/Index.cshtml b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Views/GoogleProductFeedConfig/Index.cshtml new file mode 100644 index 0000000..4f30cd3 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/Views/GoogleProductFeedConfig/Index.cshtml @@ -0,0 +1,109 @@ +@using System.Web.Mvc +@using System.Web.Mvc.Html +@using EPiServer.Framework.Web.Resources +@model StefanOlsen.Commerce.CatalogFeed.GoogleMerchant.AdminPlugin.ViewModels.AdministrationViewModel +@{ + Layout = null; +} + + + + + @Html.Raw(ClientResources.RenderResources("ShellCore")) + @Html.Raw(ClientResources.RenderResources("ShellCoreLightTheme")) + + + + +
+
+

Google Product Feed Configuration

+ Configures the Google product feed exporter, for generating an XML feed according to Google Product Feed specifications. +
+
+

+ The Google product feed exporter need to be set up in order to generate or export any catalogs.
+ Fill in the form below and click save. +

+

+
+
+ @using (Html.BeginForm("Index", null, FormMethod.Post)) + { +
+
+ @Html.LabelFor(m => m.Enabled, "Enable catalog feed export?") + @Html.CheckBoxFor(m => m.Enabled) +
+
+ @Html.LabelFor(m => m.FeedName, "The name of the feed") + @Html.TextBoxFor(m => m.FeedName, new { @class = "episize240" }) + @Html.ValidationMessageFor(m => m.FeedName) +
+
+ @Html.LabelFor(m => m.Key, "The secret key of the feed") + @Html.TextBoxFor(m => m.Key, new { @class = "episize240" }) + @Html.ValidationMessageFor(m => m.Key) + +
+
+ @Html.LabelFor(m => m.FeedExpirationMinutes, "Let each export file expire after (minutes):") + @Html.TextBoxFor(m => m.FeedExpirationMinutes, new { type = "number", @class = "episize240" }) + @Html.ValidationMessageFor(m => m.FeedExpirationMinutes) +
+
+ @Html.LabelFor(m => m.MarketIds, "Include these catalogs:") + @Html.ListBoxFor(m => m.CatalogIds, Model.AvailableCatalogs + .Select(catalog => new SelectListItem + { + Value = catalog.CatalogId.ToString(), + Text = catalog.Name + }), new { size = 5, multiple = "multiple", @class = "episize240" }) +
+
+ @Html.LabelFor(m => m.MarketIds, "Include these markets:") + @Html.ListBoxFor(m => m.MarketIds, Model.AvailableMarkets + .Select(market => new SelectListItem + { + Value = market.MarketId.Value, + Text = market.MarketName + }), new { size = 5, multiple = "multiple", @class = "episize240" }) +
+
+ @Html.LabelFor(m => m.MappingDocument, "Use this custom field mapping for feed generation:") + @Html.TextAreaFor(m => m.MappingDocument, new { @class = "episize240" }) + @Html.ValidationMessageFor(m => m.MappingDocument) + This must be a valid XML document. +
+
+ +
+ + + +
+ } +
+
+ + + diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/app.config b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/app.config new file mode 100644 index 0000000..f028203 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/app.config @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/packages.config b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/packages.config new file mode 100644 index 0000000..f4b6e47 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed.GoogleMerchant/packages.config @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/StefanOlsen.Commerce.CatalogFeed/CatalogService.cs b/StefanOlsen.Commerce.CatalogFeed/CatalogService.cs new file mode 100644 index 0000000..52e73e0 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/CatalogService.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using EPiServer; +using EPiServer.Commerce.Catalog.ContentTypes; +using EPiServer.Core; +using Mediachase.Commerce.Catalog; + +namespace StefanOlsen.Commerce.CatalogFeed +{ + public class CatalogService + { + private readonly IContentLoader _contentLoader; + private readonly IPublishedStateAssessor _publishedStateAssessor; + private readonly ReferenceConverter _referenceConverter; + + public CatalogService( + IContentLoader contentLoader, + IPublishedStateAssessor publishedStateAssessor, + ReferenceConverter referenceConverter) + { + _contentLoader = contentLoader; + _publishedStateAssessor = publishedStateAssessor; + _referenceConverter = referenceConverter; + } + + public virtual IEnumerable GetCatalogs(int[] catalogIds) + { + ContentReference catalogRoot = _referenceConverter.GetRootLink(); + IEnumerable catalogs = _contentLoader.GetChildren(catalogRoot); + + return catalogs.Where(c => catalogIds.Contains(c.CatalogId)); + } + + public virtual IEnumerable GetCatalogs() + { + ContentReference catalogRoot = _referenceConverter.GetRootLink(); + IEnumerable catalogs = _contentLoader.GetChildren(catalogRoot); + + return catalogs; + } + + public virtual NodeContent GetParentCatalogNode(ProductContent product) + { + ContentReference parentLink = product.ParentLink; + if (ContentReference.IsNullOrEmpty(parentLink)) + { + return null; + } + + CatalogContentType contentType = _referenceConverter.GetContentType(parentLink); + if (contentType != CatalogContentType.CatalogNode) + { + return null; + } + + bool exists = _contentLoader.TryGet(parentLink, product.Language, out NodeContent nodeContent); + + return exists ? nodeContent : null; + } + + public virtual IEnumerable GetVariations(ProductContent product) + { + IEnumerable variations = _contentLoader + .GetItems(product.GetVariants(), product.Language) + .OfType() + .Where(c => _publishedStateAssessor.IsPublished(c)); + + return variations; + } + + public virtual IEnumerable GetTreeEntries(ContentReference parentLink, CultureInfo defaultCulture) + where TContent : EntryContentBase + { + var childNodes = _contentLoader.GetChildren(parentLink, defaultCulture); + foreach (NodeContent childNode in childNodes) + { + foreach (var entry in GetTreeEntries(childNode.ContentLink, defaultCulture)) + { + yield return entry; + } + } + + var childEntries = _contentLoader.GetChildren(parentLink, defaultCulture); + foreach (var childEntry in childEntries) + { + yield return childEntry; + } + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Data/CatalogFeedDataService.cs b/StefanOlsen.Commerce.CatalogFeed/Data/CatalogFeedDataService.cs new file mode 100644 index 0000000..5427c16 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Data/CatalogFeedDataService.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using EPiServer.Data; +using EPiServer.Data.Dynamic; + +namespace StefanOlsen.Commerce.CatalogFeed.Data +{ + public class CatalogFeedDataService : ICatalogFeedDataService + { + private readonly DynamicDataStoreFactory _dynamicDataStoreFactory; + private const string StoreName = "FeedItemStore"; + + public CatalogFeedDataService(DynamicDataStoreFactory dynamicDataStoreFactory) + { + _dynamicDataStoreFactory = dynamicDataStoreFactory; + } + + public CatalogFeedItem Create(Uri blobId, string providerName, string marketId) + { + var catalogFeedItem = new CatalogFeedItem + { + Id = Identity.NewIdentity(), + BlobId = blobId, + ProviderName = providerName, + MarketId = marketId, + CreatedDate = DateTime.UtcNow, + ExpireDate = DateTime.UtcNow.AddDays(1) + }; + + var store = GetStore(); + store.Save(catalogFeedItem); + + return catalogFeedItem; + } + + public CatalogFeedItem Get(string feedItemId) + { + if (!Identity.TryParse(feedItemId, out Identity identity)) + { + return null; + } + + var store = GetStore(); + var feedItem = store.Load(identity); + + return feedItem; + } + + public CatalogFeedItem Get(string providerName, string marketId) + { + var store = GetStore(); + + var parameters = new Dictionary + { + {"ProviderName", providerName}, + {"MarketId", marketId} + }; + + var feedItem = store.Find(parameters) + .OrderByDescending(item => item.CreatedDate) + .FirstOrDefault(); + + return feedItem; + } + + public IEnumerable GetAll() + { + var store = GetStore(); + + return store.LoadAll(); + } + + public void Delete(Identity id) + { + var store = GetStore(); + + store.Delete(id); + } + + private DynamicDataStore GetStore() + { + return _dynamicDataStoreFactory.CreateStore(StoreName, typeof(CatalogFeedItem)); + //return DynamicDataStoreFactory.Instance.CreateStore(StoreName, typeof(CatalogFeedItem)); + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Data/CatalogFeedItem.cs b/StefanOlsen.Commerce.CatalogFeed/Data/CatalogFeedItem.cs new file mode 100644 index 0000000..53c5182 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Data/CatalogFeedItem.cs @@ -0,0 +1,24 @@ +using System; +using EPiServer.Data; +using EPiServer.Data.Dynamic; + +namespace StefanOlsen.Commerce.CatalogFeed.Data +{ + [EPiServerDataStore(AutomaticallyCreateStore = true, AutomaticallyRemapStore = true)] + public class CatalogFeedItem : IDynamicData + { + public Identity Id { get; set; } + + public Uri BlobId { get; set; } + + [EPiServerDataIndex] + public string ProviderName { get; set; } + + [EPiServerDataIndex] + public string MarketId { get; set; } + + public DateTime CreatedDate { get; set; } + + public DateTime ExpireDate { get; set; } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Data/ICatalogFeedDataService.cs b/StefanOlsen.Commerce.CatalogFeed/Data/ICatalogFeedDataService.cs new file mode 100644 index 0000000..d58972d --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Data/ICatalogFeedDataService.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using EPiServer.Data; + +namespace StefanOlsen.Commerce.CatalogFeed.Data +{ + public interface ICatalogFeedDataService + { + CatalogFeedItem Create(Uri blobId, string providerName, string marketId); + + void Delete(Identity id); + + CatalogFeedItem Get(string feedItemId); + + CatalogFeedItem Get(string providerName, string marketId); + + IEnumerable GetAll(); + } +} \ No newline at end of file diff --git a/StefanOlsen.Commerce.CatalogFeed/Enumerable.cs b/StefanOlsen.Commerce.CatalogFeed/Enumerable.cs new file mode 100644 index 0000000..81d733b --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Enumerable.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace StefanOlsen.Commerce.CatalogFeed +{ + public class Enumerable : IEnumerable + { + private readonly IEnumerable _baseEnumerable; + + public Enumerable(IEnumerable baseEnumerable) + { + _baseEnumerable = baseEnumerable; + } + + public void Add(T obj) + { + throw new NotImplementedException(); + } + + public IEnumerator GetEnumerator() + { + return _baseEnumerable?.GetEnumerator() ?? Enumerable.Empty().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/ICatalogFeedService.cs b/StefanOlsen.Commerce.CatalogFeed/ICatalogFeedService.cs new file mode 100644 index 0000000..8bf659e --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/ICatalogFeedService.cs @@ -0,0 +1,11 @@ +using System.IO; +using Mediachase.Commerce; +using StefanOlsen.Commerce.CatalogFeed.Mapping; + +namespace StefanOlsen.Commerce.CatalogFeed +{ + public interface ICatalogFeedService + { + void GetCatalogFeed(int[] catalogIds, IMarket market, string feedName, FieldMapping fieldMapping, Stream outputStream); + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/IProductDataService.cs b/StefanOlsen.Commerce.CatalogFeed/IProductDataService.cs new file mode 100644 index 0000000..2ed522d --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/IProductDataService.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using EPiServer.Commerce.Catalog.ContentTypes; +using Mediachase.Commerce; + +namespace StefanOlsen.Commerce.CatalogFeed +{ + public interface IProductDataService + { + ItemInventory GetInventory(EntryContentBase entryContent); + + IEnumerable GetPrices(IEnumerable entries, IMarket market, DateTime validOn); + } +} \ No newline at end of file diff --git a/StefanOlsen.Commerce.CatalogFeed/ItemInventory.cs b/StefanOlsen.Commerce.CatalogFeed/ItemInventory.cs new file mode 100644 index 0000000..ffd965b --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/ItemInventory.cs @@ -0,0 +1,9 @@ +namespace StefanOlsen.Commerce.CatalogFeed +{ + public class ItemInventory + { + public string Code { get; set; } + public decimal AvailableQuantity { get; set; } + public decimal PreorderQuantity { get; set; } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/ItemPrice.cs b/StefanOlsen.Commerce.CatalogFeed/ItemPrice.cs new file mode 100644 index 0000000..d9ff94f --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/ItemPrice.cs @@ -0,0 +1,13 @@ +using EPiServer.Core; + +namespace StefanOlsen.Commerce.CatalogFeed +{ + public class ItemPrice + { + public string Code { get; set; } + public ContentReference ContentLink { get; set; } + public decimal UnitPrice { get; set; } + public decimal SalePrice { get; set; } + public string Currency { get; set; } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Mapping/FieldMappingHelper.cs b/StefanOlsen.Commerce.CatalogFeed/Mapping/FieldMappingHelper.cs new file mode 100644 index 0000000..703bae4 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Mapping/FieldMappingHelper.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; + +namespace StefanOlsen.Commerce.CatalogFeed.Mapping +{ + public static class FieldMappingHelper + { + public static FieldMapping LoadFieldMapping(string mappingDocument) + { + if (mappingDocument == null) + { + throw new ArgumentNullException(nameof(mappingDocument)); + } + + var serializer = new XmlSerializer(typeof(FieldMapping)); + using (TextReader reader = new StringReader(mappingDocument)) + { + var fieldmapping = (FieldMapping)serializer.Deserialize(reader); + + return fieldmapping; + } + } + + public static bool ValidateFieldMapping(string mappingDocument) + { + if (mappingDocument == null) + { + throw new ArgumentNullException(nameof(mappingDocument)); + } + + var serializer = new XmlSerializer(typeof(FieldMapping)); + using (TextReader stringReader = new StringReader(mappingDocument)) + using (XmlReader xmlReader = new XmlTextReader(stringReader)) + { + return serializer.CanDeserialize(xmlReader); + } + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Mapping/MappingDocument.designer.cs b/StefanOlsen.Commerce.CatalogFeed/Mapping/MappingDocument.designer.cs new file mode 100644 index 0000000..fab9b56 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Mapping/MappingDocument.designer.cs @@ -0,0 +1,167 @@ +// ------------------------------------------------------------------------------ +// +// Generated by Xsd2Code. Version 3.4.1.34438 Microsoft Reciprocal License (Ms-RL) +// StefanOlsen.Commerce.CatalogFeed.MappingArrayCSharpFalseFalseFalseFalseFalseFalseFalseFalseFalseFalseFalseFalseNet40SerializeDeserializeSaveToFileLoadFromFileTrueFalseFalseTrueFalseFalseDefaultUTF8FalseTrue +// +// ------------------------------------------------------------------------------ +namespace StefanOlsen.Commerce.CatalogFeed.Mapping +{ + using System; + using System.Diagnostics; + using System.Xml.Serialization; + using System.Collections; + using System.Xml.Schema; + using System.ComponentModel; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(TypeName = "complexType.FixedFieldType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute("FixedField", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = false)] + public partial class complexTypeFixedFieldType : complexTypeBaseFieldType + { + + [System.Xml.Serialization.XmlAttributeAttribute()] + public string Value { get; set; } + + } + + [System.Xml.Serialization.XmlIncludeAttribute(typeof(complexTypeFixedFieldType))] + [System.Xml.Serialization.XmlIncludeAttribute(typeof(complexTypeMappedFieldType))] + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(TypeName = "complexType.BaseFieldType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute("complexType.BaseFieldType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = true)] + public abstract partial class complexTypeBaseFieldType + { + + [System.Xml.Serialization.XmlAttributeAttribute()] + public string FeedField { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(TypeName = "complexType.MappedFieldType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute("MappedField", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = false)] + public partial class complexTypeMappedFieldType : complexTypeBaseFieldType + { + + [System.Xml.Serialization.XmlAttributeAttribute()] + public string MetaField { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(AnonymousType = true, Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute(Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = false)] + public partial class FieldMapping + { + + private complexTypeContentType[] contentTypeField; + + [System.Xml.Serialization.XmlElementAttribute("ContentType")] + public complexTypeContentType[] ContentType + { + get + { + return this.contentTypeField; + } + set + { + this.contentTypeField = value; + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(TypeName = "complexType.ContentType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute("complexType.ContentType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = true)] + public partial class complexTypeContentType + { + + private complexTypeBaseFieldType[] fieldsField; + + public complexTypeImageGroup ImageGroup { get; set; } + + [System.Xml.Serialization.XmlAttributeAttribute()] + public simpleTypeCommerceEntityType CommerceType { get; set; } + + + [System.Xml.Serialization.XmlArrayItemAttribute("FixedField", typeof(complexTypeFixedFieldType), IsNullable = false)] + [System.Xml.Serialization.XmlArrayItemAttribute("MappedField", typeof(complexTypeMappedFieldType), IsNullable = false)] + public complexTypeBaseFieldType[] Fields + { + get + { + return this.fieldsField; + } + set + { + this.fieldsField = value; + } + } + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(TypeName = "complexType.ImageGroup", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute("complexType.ImageGroup", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = true)] + public partial class complexTypeImageGroup + { + + [System.Xml.Serialization.XmlAttributeAttribute()] + public string AssetMetaField { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.Xml.Serialization.XmlTypeAttribute(TypeName = "simpleType.CommerceEntityType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute("simpleType.CommerceEntityType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = false)] + public enum simpleTypeCommerceEntityType + { + + /// + CatalogNode, + + /// + Product, + + /// + Variation, + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Xml", "4.7.2102.0")] + [System.SerializableAttribute()] + [System.ComponentModel.DesignerCategoryAttribute("code")] + [System.Xml.Serialization.XmlTypeAttribute(TypeName = "complexType.FieldsType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd")] + [System.Xml.Serialization.XmlRootAttribute("complexType.FieldsType", Namespace = "http://stefanolsen.com/CatalogFeed.GoogleMerchant/MappingDocument.xsd", IsNullable = true)] + public partial class complexTypeFieldsType + { + + private complexTypeBaseFieldType[] itemsField; + + [System.Xml.Serialization.XmlElementAttribute("FixedField", typeof(complexTypeFixedFieldType))] + [System.Xml.Serialization.XmlElementAttribute("MappedField", typeof(complexTypeMappedFieldType))] + public complexTypeBaseFieldType[] Items + { + get + { + return this.itemsField; + } + set + { + this.itemsField = value; + } + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Mapping/MappingDocument.xsd b/StefanOlsen.Commerce.CatalogFeed/Mapping/MappingDocument.xsd new file mode 100644 index 0000000..5928e42 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Mapping/MappingDocument.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StefanOlsen.Commerce.CatalogFeed/ProductDataService.cs b/StefanOlsen.Commerce.CatalogFeed/ProductDataService.cs new file mode 100644 index 0000000..434536e --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/ProductDataService.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using EPiServer.Commerce.Catalog.ContentTypes; +using EPiServer.Commerce.Marketing; +using Mediachase.Commerce; +using Mediachase.Commerce.Catalog; +using Mediachase.Commerce.InventoryService; +using Mediachase.Commerce.Pricing; + +namespace StefanOlsen.Commerce.CatalogFeed +{ + public class ProductDataService : IProductDataService + { + private readonly IInventoryService _inventoryService; + private readonly IPriceService _priceService; + private readonly IPromotionEngine _promotionEngine; + + public ProductDataService( + IPriceService priceService, + IPromotionEngine promotionEngine, + IInventoryService inventoryService) + { + _priceService = priceService; + _promotionEngine = promotionEngine; + _inventoryService = inventoryService; + } + + public IEnumerable GetPrices( + IEnumerable entries, + IMarket market, + DateTime validOn) + { + if (entries == null) + { + throw new ArgumentNullException(nameof(entries)); + } + + var catalogKeys = entries + .Where(e => e is IPricing) + .ToDictionary(e => new CatalogKey(e.Code), e => e); + + var priceFilter = new PriceFilter + { + Currencies = market.Currencies, + CustomerPricing = new[] { CustomerPricing.AllCustomers }, + Quantity = 0M, + ReturnCustomerPricing = false + }; + + var priceValues = _priceService.GetPrices(market.MarketId, validOn, catalogKeys.Keys, priceFilter).ToList(); + priceValues = priceValues.GroupBy(pv => new { pv.CatalogKey, pv.UnitPrice.Currency }) + .Select(g => g.OrderBy(pv => pv.UnitPrice.Amount).First()).ToList(); + + foreach (var priceValue in priceValues) + { + if (!catalogKeys.TryGetValue(priceValue.CatalogKey, out EntryContentBase entry)) + { + continue; + } + + DiscountPrice discountPrice = _promotionEngine + .GetDiscountPrices(entry.ContentLink, market, priceValue.UnitPrice.Currency) + .SelectMany(dp => dp.DiscountPrices) + .OrderBy(dp => dp.DefaultPrice.Amount) + .FirstOrDefault(); + + var itemPrice = new ItemPrice + { + Code = priceValue.CatalogKey.CatalogEntryCode, + Currency = priceValue.UnitPrice.Currency, + UnitPrice = priceValue.UnitPrice.Amount, + SalePrice = discountPrice?.Price.Amount ?? priceValue.UnitPrice.Amount + }; + + yield return itemPrice; + } + } + + public ItemInventory GetInventory(EntryContentBase entryContent) + { + IList inventoryRecords = _inventoryService.QueryByEntry(new[] {entryContent.Code}); + if (inventoryRecords == null) + { + return null; + } + + decimal availableQuantity = inventoryRecords.Sum(r => r.PurchaseAvailableQuantity); + decimal preorderQuantity = inventoryRecords.Sum(r => r.PreorderAvailableQuantity); + + var itemInventory = new ItemInventory + { + Code = entryContent.Code, + AvailableQuantity = availableQuantity, + PreorderQuantity = preorderQuantity + }; + + return itemInventory; + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Properties/AssemblyInfo.cs b/StefanOlsen.Commerce.CatalogFeed/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..353a79d --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("StefanOlsen.Commerce.CatalogFeed")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("StefanOlsen.Commerce.CatalogFeed")] +[assembly: AssemblyCopyright("Copyright © 2017")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("fd6e6a92-67a6-4b9b-b94f-03de6cc237bb")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/StefanOlsen.Commerce.CatalogFeed/Settings/FeedSettings.cs b/StefanOlsen.Commerce.CatalogFeed/Settings/FeedSettings.cs new file mode 100644 index 0000000..9b17392 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Settings/FeedSettings.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Web.Mvc; +using EPiServer.Data; +using EPiServer.Data.Dynamic; + +namespace StefanOlsen.Commerce.CatalogFeed.Settings +{ + [EPiServerDataStore(AutomaticallyCreateStore = true, AutomaticallyRemapStore = true)] + public class FeedSettings : IDynamicData + { + public FeedSettings() + { + Id = Identity.NewIdentity(); + CatalogIds = string.Empty; + MarketIds = CatalogIds = string.Empty; + FeedExpirationMinutes = 60 * 24; // 1 day. + MappingDocument = string.Empty; + } + + public Identity Id { get; set; } + + [EPiServerDataIndex] + public string ProviderName { get; set; } + + public bool Enabled { get; set; } + + public string Key { get; set; } + + public string FeedName { get; set; } + + public string CatalogIds { get; set; } + + public string MarketIds { get; set; } + + public int FeedExpirationMinutes { get; set; } + + [AllowHtml] + public string MappingDocument { get; set; } + + [EPiServerIgnoreDataMember] + public int[] CatalogIdList + { + get => CatalogIds?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToArray(); + set => CatalogIds = string.Join(",", value.Select(x => x.ToString())); + } + + [EPiServerIgnoreDataMember] + public string[] MarketIdList + { + get => MarketIds?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + set => MarketIds = string.Join(",", value); + } + } +} diff --git a/StefanOlsen.Commerce.CatalogFeed/Settings/SettingsRepository.cs b/StefanOlsen.Commerce.CatalogFeed/Settings/SettingsRepository.cs new file mode 100644 index 0000000..df84e11 --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/Settings/SettingsRepository.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using EPiServer.Data.Dynamic; + +namespace StefanOlsen.Commerce.CatalogFeed.Settings +{ + public class SettingsRepository + { + private readonly DynamicDataStoreFactory _dynamicDataStoreFactory; + + public SettingsRepository(DynamicDataStoreFactory dynamicDataStoreFactory) + { + _dynamicDataStoreFactory = dynamicDataStoreFactory; + } + + public FeedSettings GetFeedSettings(string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) + { + throw new ArgumentException("Null or empty value is not allowed.", nameof(providerName)); + } + + var store = GetStore(); + + var settings = store.Find("ProviderName", providerName).FirstOrDefault(); + + return settings; + } + + public void Save(FeedSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + var store = GetStore(); + + store.Save(settings, settings.Id); + } + + private DynamicDataStore GetStore() + { + return _dynamicDataStoreFactory.CreateStore(typeof(FeedSettings)); + } + } +} + diff --git a/StefanOlsen.Commerce.CatalogFeed/StefanOlsen.Commerce.CatalogFeed.csproj b/StefanOlsen.Commerce.CatalogFeed/StefanOlsen.Commerce.CatalogFeed.csproj new file mode 100644 index 0000000..ca6320f --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/StefanOlsen.Commerce.CatalogFeed.csproj @@ -0,0 +1,215 @@ + + + + + Debug + AnyCPU + {FD6E6A92-67A6-4B9B-B94F-03DE6CC237BB} + Library + Properties + StefanOlsen.Commerce.CatalogFeed + StefanOlsen.Commerce.CatalogFeed + v4.6.2 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\AjaxControlToolkit.dll + + + ..\packages\AuthorizeNet.1.9.2\lib\AuthorizeNet.dll + + + ..\packages\Castle.Core.3.3.3\lib\net45\Castle.Core.dll + + + ..\packages\Castle.Windsor.3.3.0\lib\net45\Castle.Windsor.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.ApplicationModules.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\EPiServer.Business.Commerce.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\EPiServer.Commerce.Internal.Migration.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.Configuration.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Data.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Data.Cache.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.Enterprise.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Events.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Framework.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.ImageLibrary.dll + + + ..\packages\EPiServer.Framework.10.10.3\lib\net45\EPiServer.Licensing.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.LinkAnalyzer.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.Web.WebControls.dll + + + ..\packages\EPiServer.CMS.Core.10.10.3\lib\net45\EPiServer.XForms.dll + + + ..\packages\SharpZipLib.0.86.0\lib\20\ICSharpCode.SharpZipLib.dll + + + ..\packages\Lucene.Net.3.0.3\lib\NET40\Lucene.Net.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.BusinessFoundation.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.BusinessFoundation.Data.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Marketing.Validators.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Plugins.Payment.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Plugins.Shipping.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Website.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Commerce.Workflow.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.DataProvider.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.FileUploader.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.MetaDataPlus.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Search.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Search.Extensions.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.Search.LuceneSearchProvider.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.SqlDataProvider.dll + + + ..\packages\EPiServer.Commerce.Core.11.2.2\lib\net452\Mediachase.WebConsoleLib.dll + + + ..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll + + + ..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll + + + ..\packages\structuremap-signed.3.1.9.463\lib\net40\StructureMap.dll + + + ..\packages\structuremap-signed.3.1.9.463\lib\net40\StructureMap.Net4.dll + + + ..\packages\structuremap.web-signed.3.1.6.186\lib\net40\StructureMap.Web.dll + + + + + ..\packages\Microsoft.Tpl.Dataflow.4.5.24\lib\portable-net45+win8+wpa81\System.Threading.Tasks.Dataflow.dll + True + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.Helpers.dll + + + ..\packages\Microsoft.AspNet.Mvc.5.2.3\lib\net45\System.Web.Mvc.dll + + + ..\packages\Microsoft.AspNet.Razor.3.2.3\lib\net45\System.Web.Razor.dll + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.dll + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Deployment.dll + + + ..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Razor.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Designer + + + + + \ No newline at end of file diff --git a/StefanOlsen.Commerce.CatalogFeed/app.config b/StefanOlsen.Commerce.CatalogFeed/app.config new file mode 100644 index 0000000..e3dbd3a --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/app.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/StefanOlsen.Commerce.CatalogFeed/packages.config b/StefanOlsen.Commerce.CatalogFeed/packages.config new file mode 100644 index 0000000..16a673c --- /dev/null +++ b/StefanOlsen.Commerce.CatalogFeed/packages.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file