diff --git a/src/Libraries/Nop.Services/Customers/CustomerService.cs b/src/Libraries/Nop.Services/Customers/CustomerService.cs index b38cfab0005..b7b4ffb84f3 100644 --- a/src/Libraries/Nop.Services/Customers/CustomerService.cs +++ b/src/Libraries/Nop.Services/Customers/CustomerService.cs @@ -307,15 +307,15 @@ public virtual async Task> GetCustomersWithShoppingCartsAsy //filter customers by billing country if (countryId > 0) customers = from c in customers - join a in _customerAddressRepository.Table on c.BillingAddressId equals a.Id - where a.CountryId == countryId - select c; + join a in _customerAddressRepository.Table on c.BillingAddressId equals a.Id + where a.CountryId == countryId + select c; var customersWithCarts = from c in customers - join item in items on c.Id equals item.CustomerId - //we change ordering for the MySQL engine to avoid problems with the ONLY_FULL_GROUP_BY server property that is set by default since the 5.7.5 version - orderby _dataProvider.ConfigurationName == "MySql" ? c.CreatedOnUtc : item.CreatedOnUtc descending - select c; + join item in items on c.Id equals item.CustomerId + //we change ordering for the MySQL engine to avoid problems with the ONLY_FULL_GROUP_BY server property that is set by default since the 5.7.5 version + orderby _dataProvider.ConfigurationName == "MySql" ? c.CreatedOnUtc : item.CreatedOnUtc descending + select c; return await customersWithCarts.Distinct().ToPagedListAsync(pageIndex, pageSize); } @@ -401,8 +401,8 @@ public virtual async Task> GetCustomersByGuidsAsync(Guid[] custo return null; var query = from c in _customerRepository.Table - where customerGuids.Contains(c.CustomerGuid) - select c; + where customerGuids.Contains(c.CustomerGuid) + select c; var customers = await query.ToListAsync(); return customers; @@ -422,9 +422,9 @@ public virtual async Task GetCustomerByGuidAsync(Guid customerGuid) return null; var query = from c in _customerRepository.Table - where c.CustomerGuid == customerGuid - orderby c.Id - select c; + where c.CustomerGuid == customerGuid + orderby c.Id + select c; return await _shortTermCacheManager.GetAsync(async () => await query.FirstOrDefaultAsync(), NopCustomerServicesDefaults.CustomerByGuidCacheKey, customerGuid); } @@ -443,9 +443,9 @@ public virtual async Task GetCustomerByEmailAsync(string email) return null; var query = from c in _customerRepository.Table - orderby c.Id - where c.Email == email - select c; + orderby c.Id + where c.Email == email + select c; var customer = await query.FirstOrDefaultAsync(); return customer; @@ -465,9 +465,9 @@ public virtual async Task GetCustomerBySystemNameAsync(string systemNa return null; var query = from c in _customerRepository.Table - orderby c.Id - where c.SystemName == systemName - select c; + orderby c.Id + where c.SystemName == systemName + select c; var customer = await _shortTermCacheManager.GetAsync(async () => await query.FirstOrDefaultAsync(), NopCustomerServicesDefaults.CustomerBySystemNameCacheKey, systemName); @@ -564,9 +564,9 @@ public virtual async Task GetCustomerByUsernameAsync(string username) return null; var query = from c in _customerRepository.Table - orderby c.Id - where c.Username == username - select c; + orderby c.Id + where c.Username == username + select c; var customer = await query.FirstOrDefaultAsync(); return customer; @@ -682,28 +682,28 @@ public virtual async Task DeleteGuestCustomersAsync(DateTime? createdFromUt var guestRole = await GetCustomerRoleBySystemNameAsync(NopCustomerDefaults.GuestsRoleName); var allGuestCustomers = from guest in _customerRepository.Table - join ccm in _customerCustomerRoleMappingRepository.Table on guest.Id equals ccm.CustomerId - where ccm.CustomerRoleId == guestRole.Id - select guest; + join ccm in _customerCustomerRoleMappingRepository.Table on guest.Id equals ccm.CustomerId + where ccm.CustomerRoleId == guestRole.Id + select guest; var guestsToDelete = from guest in _customerRepository.Table - join g in allGuestCustomers on guest.Id equals g.Id - from sCart in _shoppingCartRepository.Table.Where(sci => sci.CustomerId == guest.Id).DefaultIfEmpty() - from order in _orderRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - from blogComment in _blogCommentRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - from newsComment in _newsCommentRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - from productReview in _productReviewRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - from productReviewHelpfulness in _productReviewHelpfulnessRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - from pollVotingRecord in _pollVotingRecordRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - from forumTopic in _forumTopicRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - from forumPost in _forumPostRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() - where (!onlyWithoutShoppingCart || sCart == null) && - order == null && blogComment == null && newsComment == null && productReview == null && productReviewHelpfulness == null && - pollVotingRecord == null && forumTopic == null && forumPost == null && - !guest.IsSystemAccount && - (createdFromUtc == null || guest.CreatedOnUtc > createdFromUtc) && - (createdToUtc == null || guest.CreatedOnUtc < createdToUtc) - select new { CustomerId = guest.Id }; + join g in allGuestCustomers on guest.Id equals g.Id + from sCart in _shoppingCartRepository.Table.Where(sci => sci.CustomerId == guest.Id).DefaultIfEmpty() + from order in _orderRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + from blogComment in _blogCommentRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + from newsComment in _newsCommentRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + from productReview in _productReviewRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + from productReviewHelpfulness in _productReviewHelpfulnessRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + from pollVotingRecord in _pollVotingRecordRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + from forumTopic in _forumTopicRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + from forumPost in _forumPostRepository.Table.Where(o => o.CustomerId == guest.Id).DefaultIfEmpty() + where (!onlyWithoutShoppingCart || sCart == null) && + order == null && blogComment == null && newsComment == null && productReview == null && productReviewHelpfulness == null && + pollVotingRecord == null && forumTopic == null && forumPost == null && + !guest.IsSystemAccount && + (createdFromUtc == null || guest.CreatedOnUtc > createdFromUtc) && + (createdToUtc == null || guest.CreatedOnUtc < createdToUtc) + select new { CustomerId = guest.Id }; await using var tmpGuests = await _dataProvider.CreateTempDataStorageAsync("tmp_guestsToDelete", guestsToDelete); await using var tmpAddresses = await _dataProvider.CreateTempDataStorageAsync("tmp_guestsAddressesToDelete", @@ -1215,9 +1215,9 @@ public virtual async Task GetCustomerRoleBySystemNameAsync(string var key = _staticCacheManager.PrepareKeyForDefaultCache(NopCustomerServicesDefaults.CustomerRolesBySystemNameCacheKey, systemName); var query = from cr in _customerRoleRepository.Table - orderby cr.Id - where cr.SystemName == systemName - select cr; + orderby cr.Id + where cr.SystemName == systemName + select cr; var customerRole = await _staticCacheManager.GetAsync(key, async () => await query.FirstOrDefaultAsync()); @@ -1615,9 +1615,9 @@ public virtual async Task InsertCustomerAddressAsync(Customer customer, Address public virtual async Task> GetAddressesByCustomerIdAsync(int customerId) { var query = from address in _customerAddressRepository.Table - join cam in _customerAddressMappingRepository.Table on address.Id equals cam.AddressId - where cam.CustomerId == customerId - select address; + join cam in _customerAddressMappingRepository.Table on address.Id equals cam.AddressId + where cam.CustomerId == customerId + select address; return await _shortTermCacheManager.GetAsync(async () => await query.ToListAsync(), NopCustomerServicesDefaults.CustomerAddressesCacheKey, customerId); } @@ -1637,9 +1637,9 @@ public virtual async Task
GetCustomerAddressAsync(int customerId, int a return null; var query = from address in _customerAddressRepository.Table - join cam in _customerAddressMappingRepository.Table on address.Id equals cam.AddressId - where cam.CustomerId == customerId && address.Id == addressId - select address; + join cam in _customerAddressMappingRepository.Table on address.Id equals cam.AddressId + where cam.CustomerId == customerId && address.Id == addressId + select address; return await _shortTermCacheManager.GetAsync(async () => await query.FirstOrDefaultAsync(), NopCustomerServicesDefaults.CustomerAddressCacheKey, customerId, addressId); } @@ -1676,5 +1676,60 @@ public virtual async Task
GetCustomerShippingAddressAsync(Customer cust #endregion + #region Customer Merging + + public virtual async Task MergeCustomersAsync(Customer fromCustomer, Customer toCustomer, bool deleteFromCustomer = true) + { + + var fromOrders = await (from o in _orderRepository.Table + where o.CustomerId == fromCustomer.Id + select o).ToListAsync(); + + foreach (var order in fromOrders) + { + order.CustomerId = toCustomer.Id; + await _orderRepository.UpdateAsync(order); + } + + var fromAddresses = await (from a in _customerAddressMappingRepository.Table + where a.CustomerId == fromCustomer.Id + select a).ToListAsync(); + + foreach (var address in fromAddresses) + { + address.CustomerId = toCustomer.Id; + await _customerAddressMappingRepository.InsertAsync(new CustomerAddressMapping() { AddressId = address.AddressId, CustomerId = toCustomer.Id }); + } + + await _customerAddressMappingRepository.DeleteAsync(fromAddresses); + + if (fromAddresses.Any()) + await _staticCacheManager.RemoveByPrefixAsync(NopCustomerServicesDefaults.CustomerAddressesByCustomerPrefix, toCustomer); + + var anyRolesModified = false; + foreach (var role in await GetCustomerRolesAsync(fromCustomer, true)) + { + if (!(await IsInCustomerRoleAsync(toCustomer, role.SystemName))) + { + await AddCustomerRoleMappingAsync(new CustomerCustomerRoleMapping + { + CustomerId = toCustomer.Id, + CustomerRoleId = role.Id + }); + anyRolesModified = true; + } + } + + if (anyRolesModified) + await _staticCacheManager.RemoveByPrefixAsync(NopCustomerServicesDefaults.CustomerCustomerRolesPrefix); + + if (deleteFromCustomer) + await _customerRepository.DeleteAsync(fromCustomer); + + } + + #endregion + + #endregion } \ No newline at end of file diff --git a/src/Libraries/Nop.Services/Customers/ICustomerService.cs b/src/Libraries/Nop.Services/Customers/ICustomerService.cs index 92d70823a65..12d9c1babbf 100644 --- a/src/Libraries/Nop.Services/Customers/ICustomerService.cs +++ b/src/Libraries/Nop.Services/Customers/ICustomerService.cs @@ -648,4 +648,10 @@ Task> GetCustomerPasswordsAsync(int? customerId = null, Task InsertCustomerAddressAsync(Customer customer, Address address); #endregion + + #region Customer Merging + + Task MergeCustomersAsync(Customer fromCustomer, Customer toCustomer, bool deleteFromCustomer = true); + + #endregion } \ No newline at end of file diff --git a/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml b/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml index 1f1757ccd66..7e90f3e44c4 100644 --- a/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml +++ b/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml @@ -11904,6 +11904,18 @@ The address has been updated successfully. + + Merge Customer + + + Requested merge customer does not exist, has been deleted or already merged. + + + The same customer cannot be merged into itself. + + + Delete customer after Merge + You can't deactivate the last administrator. At least one administrator account should exists. diff --git a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs index d20a4043377..bdd1c6f2b12 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs @@ -490,6 +490,84 @@ await _customerActivityService.InsertActivityAsync("AddNewCustomer", return View(model); } + [HttpGet] + [CheckPermission(StandardPermission.Customers.CUSTOMERS_CREATE_EDIT_DELETE)] + public virtual async Task Merge(int id) + { + if (await _customerService.GetCustomerByIdAsync(id) is Customer customer) + { + var customerModel = await _customerModelFactory.PrepareCustomerModelAsync(new CustomerModel(), customer); + customerModel.FullName = await _customerService.GetCustomerFullNameAsync(customer); + return View(new CustomerMergeModel(customerModel)); + } + + _notificationService.WarningNotification(await _localizationService.GetResourceAsync("Admin.Customers.Customers.MergeCustomerError")); + return RedirectToAction("List"); + } + + [HttpPost] + [CheckPermission(StandardPermission.Customers.CUSTOMERS_CREATE_EDIT_DELETE)] + public virtual async Task MergeCustomerSearch(int? id, CustomerMergeModel searchModel) + { + if (id.HasValue) + { + if (await _customerService.GetCustomerByIdAsync(id.Value) is Customer customer) + { + var fullName = await _customerService.GetCustomerFullNameAsync(customer); + return Json(new { id = customer.Id, fullName = fullName, email = customer.Email }); + } + else + { + return new NullJsonResult(); + } + } + else + { + searchModel.SelectedCustomerRoleIds = new List { (await _customerService.GetCustomerRoleBySystemNameAsync(NopCustomerDefaults.RegisteredRoleName)).Id }; + var model = searchModel == null ? new() : await _customerModelFactory.PrepareCustomerListModelAsync(searchModel); + model.Data = model.Data.Where(c => c.Id != searchModel.CurrentCustomerId).ToList(); //exclude the customer being merged from the list + return Json(model); + } + } + + [HttpPost] + [CheckPermission(StandardPermission.Customers.CUSTOMERS_CREATE_EDIT_DELETE)] + public virtual async Task Merge(CustomerMergeModel model) + { + var mergeModel = model.Merge; + if (await _customerService.GetCustomerByIdAsync(mergeModel.FromId) is Customer fromCustomer && + !fromCustomer.Deleted && + await _customerService.GetCustomerByIdAsync(mergeModel.ToId) is Customer toCustomer && + !toCustomer.Deleted) + { + if (fromCustomer.Id == toCustomer.Id) + { + //confirm that we are not trying to merge the same customer + _notificationService.WarningNotification(await _localizationService.GetResourceAsync("Admin.Customers.Customers.MergeCustomerError.SameCustomers")); + return RedirectToAction("Merge", new { id = mergeModel.FromId }); + } + + int resultId; + if (mergeModel.FromIsSource) + { + await _customerService.MergeCustomersAsync(fromCustomer, toCustomer, mergeModel.DeleteMergedCustomer); + resultId = toCustomer.Id; + } + else + { + await _customerService.MergeCustomersAsync(toCustomer, fromCustomer, mergeModel.DeleteMergedCustomer); + resultId = fromCustomer.Id; + } + return RedirectToAction("Edit", new { id = resultId }); + } + else + { + //should be locale resource + _notificationService.WarningNotification(await _localizationService.GetResourceAsync("Admin.Customers.Customers.MergeCustomerError")); + return RedirectToAction("Merge", new { id = mergeModel.FromId }); + } + } + [CheckPermission(StandardPermission.Customers.CUSTOMERS_VIEW)] public virtual async Task Edit(int id) { @@ -877,7 +955,7 @@ public virtual async Task Delete(int id) foreach (var store in await _storeService.GetAllStoresAsync()) { var subscription = await _newsLetterSubscriptionService.GetNewsLetterSubscriptionByEmailAndStoreIdAsync(customerEmail, store.Id); - + if (subscription != null) await _newsLetterSubscriptionService.DeleteNewsLetterSubscriptionAsync(subscription); } @@ -1525,7 +1603,7 @@ public virtual async Task GdprExport(int id) { //log //_gdprService.InsertLog(customer, 0, GdprRequestType.ExportData, await _localizationService.GetResource("Gdpr.Exported")); - + //export var store = await _storeContext.GetCurrentStoreAsync(); var bytes = await _exportManager.ExportCustomerGdprInfoToXlsxAsync(customer, store.Id); diff --git a/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs b/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs new file mode 100644 index 00000000000..b8466423ec6 --- /dev/null +++ b/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs @@ -0,0 +1,37 @@ +using Nop.Web.Framework.Mvc.ModelBinding; + +namespace Nop.Web.Areas.Admin.Models.Customers; + +public partial record CustomerMergeModel : CustomerSearchModel +{ + public CustomerModel FromCustomer { get; set; } + public int CurrentCustomerId { get; set; } + + public MergeModel Merge { get; set; } = new(); + + public CustomerMergeModel() { } + + public CustomerMergeModel(CustomerModel customer) + { + FromCustomer = customer; + CurrentCustomerId = customer.Id; + Merge = new() + { + FromId = customer.Id, + DeleteMergedCustomer = true, + FromIsSource = true + }; + } + + public record MergeModel + { + public int FromId { get; set; } + + public int ToId { get; set; } + + [NopResourceDisplayName("Admin.Customers.CustomerMerge.Fields.DeleteMergedCustomer")] + public bool DeleteMergedCustomer { get; set; } + + public bool FromIsSource { get; set; } + } +} \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Edit.cshtml b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Edit.cshtml index 9b611fab135..0e5ba34d427 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Edit.cshtml +++ b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Edit.cshtml @@ -82,6 +82,10 @@ } + + + @T("Admin.Customers.Customers.MergeCustomer") + @T("Admin.Common.Delete") diff --git a/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml new file mode 100644 index 00000000000..fe7218b1926 --- /dev/null +++ b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml @@ -0,0 +1,259 @@ +@model CustomerMergeModel +@{ + ViewBag.PageTitle = T("Admin.Customers.Customers.MergeCustomer").Text; + //active menu item (system name) + NopHtml.SetActiveMenuItemSystemName("Customers list"); +} + +
+ + +
+

+ @T("Admin.Customers.Customers.MergeCustomer") + + + @T("Admin.Customers.Customers.Addresses.BackToCustomer") + +

+
+ +
+
+
+
+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+ @if (Model.UsernamesEnabled) + { +
+
+ +
+
+ +
+
+ } +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + @{ + var gridModel = new DataTablesModel + { + Name = "customers-grid", + UrlRead = new DataUrl("MergeCustomerSearch", "Customer", null), + SearchButtonId = "search-customers", + Length = Model.PageSize, + LengthMenu = Model.AvailablePageSizes, + Filters = new List + { + new FilterParameter(nameof(Model.SearchEmail)), + new FilterParameter(nameof(Model.SearchUsername)), + new FilterParameter(nameof(Model.SearchFirstName)), + new FilterParameter(nameof(Model.SearchLastName)), + new FilterParameter("CurrentCustomerId", Model.CurrentCustomerId) + } + }; + + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.Id)) + { + IsMasterCheckBox = true, + Render = new RenderCheckBox("checkbox_customers"), + ClassName = NopColumnClassDefaults.CenterAll, + Width = "30" + }); + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.Email)) + { + Title = T("Admin.Customers.Customers.Fields.Email").Text + }); + if (Model.AvatarEnabled) + { + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.AvatarUrl)) + { + Title = T("Admin.Customers.Customers.Fields.Avatar").Text, + Width = "140", + Render = new RenderPicture() + }); + } + if (Model.UsernamesEnabled) + { + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.Username)) + { + Title = T("Admin.Customers.Customers.Fields.Username").Text + }); + } + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.FullName)) + { + Title = T("Admin.Customers.Customers.Fields.FullName").Text + }); + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.CustomerRoleNames)) + { + Title = T("Admin.Customers.Customers.Fields.CustomerRoles").Text, + Width = "100" + }); + if (Model.CompanyEnabled) + { + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.Company)) + { + Title = T("Admin.Customers.Customers.Fields.Company").Text + }); + } + if (Model.PhoneEnabled) + { + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.Phone)) + { + Title = T("Admin.Customers.Customers.Fields.Phone").Text + }); + } + if (Model.ZipPostalCodeEnabled) + { + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.ZipPostalCode)) + { + Title = T("Admin.Customers.Customers.Fields.ZipPostalCode").Text + }); + } + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.Active)) + { + Title = T("Admin.Customers.Customers.Fields.Active").Text, + Width = "70", + ClassName = NopColumnClassDefaults.CenterAll, + Render = new RenderBoolean() + }); + gridModel.ColumnCollection.Add(new ColumnProperty(nameof(CustomerModel.Id)) + { + Title = T("Admin.Common.Select").Text, + Width = "80", + ClassName = NopColumnClassDefaults.Button, + Render = new RenderButtonCustom(NopButtonClassDefaults.Olive, T("Admin.Common.Select").Text) + { + OnClickFunctionName = $"selectCustomer", + } + }); + } + @await Html.PartialAsync("Table", gridModel) +
+
+
+
+
+
+
+ +