From f77bc3961ffc564bf3730561bf00b0379631e253 Mon Sep 17 00:00:00 2001 From: Dan Fehrenbach Date: Thu, 5 Jun 2025 14:36:07 -0500 Subject: [PATCH 1/5] implements #7728 --- .../Nop.Services/Customers/CustomerService.cs | 139 +++------- .../Customers/ICustomerService.cs | 6 + .../Localization/defaultResources.nopres.xml | 6 + .../Admin/Controllers/CustomerController.cs | 71 +++++ .../Models/Customers/CustomerMergeModel.cs | 7 + .../Areas/Admin/Views/Customer/Edit.cshtml | 4 + .../Areas/Admin/Views/Customer/Merge.cshtml | 246 ++++++++++++++++++ 7 files changed, 375 insertions(+), 104 deletions(-) create mode 100644 src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs create mode 100644 src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml diff --git a/src/Libraries/Nop.Services/Customers/CustomerService.cs b/src/Libraries/Nop.Services/Customers/CustomerService.cs index b38cfab0005..3c8b0fda7ba 100644 --- a/src/Libraries/Nop.Services/Customers/CustomerService.cs +++ b/src/Libraries/Nop.Services/Customers/CustomerService.cs @@ -1553,125 +1553,56 @@ public virtual async Task IsPasswordExpiredAsync(Customer customer) #endregion - #region Customer address mapping - /// - /// Remove a customer-address mapping record - /// - /// Customer - /// Address - /// A task that represents the asynchronous operation - public virtual async Task RemoveCustomerAddressAsync(Customer customer, Address address) - { - ArgumentNullException.ThrowIfNull(customer); - - if (await _customerAddressMappingRepository.Table - .FirstOrDefaultAsync(m => m.AddressId == address.Id && m.CustomerId == customer.Id) - is CustomerAddressMapping mapping) - { - if (customer.BillingAddressId == address.Id) - customer.BillingAddressId = null; - if (customer.ShippingAddressId == address.Id) - customer.ShippingAddressId = null; + #region Customer Merging - await _customerAddressMappingRepository.DeleteAsync(mapping); - } - } - - /// - /// Inserts a customer-address mapping record - /// - /// Customer - /// Address - /// A task that represents the asynchronous operation - public virtual async Task InsertCustomerAddressAsync(Customer customer, Address address) + public virtual async Task MergeCustomersAsync(Customer fromCustomer, Customer toCustomer, bool deleteFromCustomer = true) { - ArgumentNullException.ThrowIfNull(customer); - ArgumentNullException.ThrowIfNull(address); + var fromOrders = await (from o in _orderRepository.Table + where o.CustomerId == fromCustomer.Id + select o).ToListAsync(); - if (await _customerAddressMappingRepository.Table - .FirstOrDefaultAsync(m => m.AddressId == address.Id && m.CustomerId == customer.Id) - is null) + foreach (var order in fromOrders) { - var mapping = new CustomerAddressMapping - { - AddressId = address.Id, - CustomerId = customer.Id - }; - - await _customerAddressMappingRepository.InsertAsync(mapping); + order.CustomerId = toCustomer.Id; + await _orderRepository.UpdateAsync(order); } - } - - /// - /// Gets a list of addresses mapped to customer - /// - /// Customer identifier - /// - /// A task that represents the asynchronous operation - /// The task result contains the result - /// - 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; - return await _shortTermCacheManager.GetAsync(async () => await query.ToListAsync(), NopCustomerServicesDefaults.CustomerAddressesCacheKey, customerId); - } + var fromAddresses = await (from a in _customerAddressMappingRepository.Table + where a.CustomerId == fromCustomer.Id + select a).ToListAsync(); - /// - /// Gets a address mapped to customer - /// - /// Customer identifier - /// Address identifier - /// - /// A task that represents the asynchronous operation - /// The task result contains the result - /// - public virtual async Task
GetCustomerAddressAsync(int customerId, int addressId) - { - if (customerId == 0 || addressId == 0) - return null; + foreach (var address in fromAddresses) + { + address.CustomerId = toCustomer.Id; + await _customerAddressMappingRepository.InsertAsync(new CustomerAddressMapping() { AddressId = address.AddressId, CustomerId = toCustomer.Id }); + } - 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; + await _customerAddressMappingRepository.DeleteAsync(fromAddresses); - return await _shortTermCacheManager.GetAsync(async () => await query.FirstOrDefaultAsync(), NopCustomerServicesDefaults.CustomerAddressCacheKey, customerId, addressId); - } + if (fromAddresses.Any()) + await _staticCacheManager.RemoveByPrefixAsync(NopCustomerServicesDefaults.CustomerAddressesByCustomerPrefix, toCustomer); - /// - /// Gets a customer billing address - /// - /// Customer identifier - /// - /// A task that represents the asynchronous operation - /// The task result contains the result - /// - public virtual async Task
GetCustomerBillingAddressAsync(Customer customer) - { - ArgumentNullException.ThrowIfNull(customer); + 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; + } + } - return await GetCustomerAddressAsync(customer.Id, customer.BillingAddressId ?? 0); - } + if (anyRolesModified) + await _staticCacheManager.RemoveByPrefixAsync(NopCustomerServicesDefaults.CustomerCustomerRolesPrefix); - /// - /// Gets a customer shipping address - /// - /// Customer - /// - /// A task that represents the asynchronous operation - /// The task result contains the result - /// - public virtual async Task
GetCustomerShippingAddressAsync(Customer customer) - { - ArgumentNullException.ThrowIfNull(customer); + await _customerRepository.DeleteAsync(fromCustomer); - return await GetCustomerAddressAsync(customer.Id, customer.ShippingAddressId ?? 0); } #endregion 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..5d60df721be 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,12 @@ The address has been updated successfully. + + Merge Customer + + + Requested merge customer does not exist, has been deleted or already merged. + 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..ca97c236a94 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs @@ -490,6 +490,77 @@ 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() { FromCustomer = customerModel, CurrentCustomerId = customer.Id }); + } + + //should be locale resource + _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(int fromId, int toId, bool fromIsSource) + { + if (await _customerService.GetCustomerByIdAsync(fromId) is Customer fromCustomer && + !fromCustomer.Deleted && + await _customerService.GetCustomerByIdAsync(toId) is Customer toCustomer && + !toCustomer.Deleted) + { + int resultId; + if (fromIsSource) + { + await _customerService.MergeCustomersAsync(fromCustomer, toCustomer); + resultId = toCustomer.Id; + } + else + { + await _customerService.MergeCustomersAsync(toCustomer, fromCustomer); + 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 = fromId }); + } + } + [CheckPermission(StandardPermission.Customers.CUSTOMERS_VIEW)] public virtual async Task Edit(int 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..9f4d791ae96 --- /dev/null +++ b/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs @@ -0,0 +1,7 @@ +namespace Nop.Web.Areas.Admin.Models.Customers; + +public partial record CustomerMergeModel : CustomerSearchModel +{ + public CustomerModel FromCustomer { get; set; } + public int CurrentCustomerId { 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..ba50edb96a6 --- /dev/null +++ b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml @@ -0,0 +1,246 @@ +@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) +
+
+
+
+
+
+
+ + From d0eb8625e992a76b4e42f60f4faf3bba0c3def22 Mon Sep 17 00:00:00 2001 From: Dan Fehrenbach Date: Thu, 5 Jun 2025 14:41:45 -0500 Subject: [PATCH 2/5] fix accidental deletions --- .../Nop.Services/Customers/CustomerService.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/Libraries/Nop.Services/Customers/CustomerService.cs b/src/Libraries/Nop.Services/Customers/CustomerService.cs index 3c8b0fda7ba..15a74ad155d 100644 --- a/src/Libraries/Nop.Services/Customers/CustomerService.cs +++ b/src/Libraries/Nop.Services/Customers/CustomerService.cs @@ -1553,6 +1553,128 @@ public virtual async Task IsPasswordExpiredAsync(Customer customer) #endregion + #region Customer address mapping + + /// + /// Remove a customer-address mapping record + /// + /// Customer + /// Address + /// A task that represents the asynchronous operation + public virtual async Task RemoveCustomerAddressAsync(Customer customer, Address address) + { + ArgumentNullException.ThrowIfNull(customer); + + if (await _customerAddressMappingRepository.Table + .FirstOrDefaultAsync(m => m.AddressId == address.Id && m.CustomerId == customer.Id) + is CustomerAddressMapping mapping) + { + if (customer.BillingAddressId == address.Id) + customer.BillingAddressId = null; + if (customer.ShippingAddressId == address.Id) + customer.ShippingAddressId = null; + + await _customerAddressMappingRepository.DeleteAsync(mapping); + } + } + + /// + /// Inserts a customer-address mapping record + /// + /// Customer + /// Address + /// A task that represents the asynchronous operation + public virtual async Task InsertCustomerAddressAsync(Customer customer, Address address) + { + ArgumentNullException.ThrowIfNull(customer); + + ArgumentNullException.ThrowIfNull(address); + + if (await _customerAddressMappingRepository.Table + .FirstOrDefaultAsync(m => m.AddressId == address.Id && m.CustomerId == customer.Id) + is null) + { + var mapping = new CustomerAddressMapping + { + AddressId = address.Id, + CustomerId = customer.Id + }; + + await _customerAddressMappingRepository.InsertAsync(mapping); + } + } + + /// + /// Gets a list of addresses mapped to customer + /// + /// Customer identifier + /// + /// A task that represents the asynchronous operation + /// The task result contains the result + /// + 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; + + return await _shortTermCacheManager.GetAsync(async () => await query.ToListAsync(), NopCustomerServicesDefaults.CustomerAddressesCacheKey, customerId); + } + + /// + /// Gets a address mapped to customer + /// + /// Customer identifier + /// Address identifier + /// + /// A task that represents the asynchronous operation + /// The task result contains the result + /// + public virtual async Task
GetCustomerAddressAsync(int customerId, int addressId) + { + if (customerId == 0 || addressId == 0) + 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; + + return await _shortTermCacheManager.GetAsync(async () => await query.FirstOrDefaultAsync(), NopCustomerServicesDefaults.CustomerAddressCacheKey, customerId, addressId); + } + + /// + /// Gets a customer billing address + /// + /// Customer identifier + /// + /// A task that represents the asynchronous operation + /// The task result contains the result + /// + public virtual async Task
GetCustomerBillingAddressAsync(Customer customer) + { + ArgumentNullException.ThrowIfNull(customer); + + return await GetCustomerAddressAsync(customer.Id, customer.BillingAddressId ?? 0); + } + + /// + /// Gets a customer shipping address + /// + /// Customer + /// + /// A task that represents the asynchronous operation + /// The task result contains the result + /// + public virtual async Task
GetCustomerShippingAddressAsync(Customer customer) + { + ArgumentNullException.ThrowIfNull(customer); + + return await GetCustomerAddressAsync(customer.Id, customer.ShippingAddressId ?? 0); + } + + #endregion #region Customer Merging @@ -1607,5 +1729,6 @@ await AddCustomerRoleMappingAsync(new CustomerCustomerRoleMapping #endregion + #endregion } \ No newline at end of file From df9d456090ad7f310b2cf56761e5116c28776d10 Mon Sep 17 00:00:00 2001 From: Dan Fehrenbach Date: Fri, 6 Jun 2025 10:10:27 -0500 Subject: [PATCH 3/5] support choice to delete, or not, merged customer. moved merge button --- .../Nop.Services/Customers/CustomerService.cs | 103 +++++++++--------- .../Localization/defaultResources.nopres.xml | 3 + .../Admin/Controllers/CustomerController.cs | 10 +- .../Models/Customers/CustomerMergeModel.cs | 7 +- .../Areas/Admin/Views/Customer/Merge.cshtml | 25 ++++- 5 files changed, 85 insertions(+), 63 deletions(-) diff --git a/src/Libraries/Nop.Services/Customers/CustomerService.cs b/src/Libraries/Nop.Services/Customers/CustomerService.cs index 15a74ad155d..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); } @@ -1723,7 +1723,8 @@ await AddCustomerRoleMappingAsync(new CustomerCustomerRoleMapping if (anyRolesModified) await _staticCacheManager.RemoveByPrefixAsync(NopCustomerServicesDefaults.CustomerCustomerRolesPrefix); - await _customerRepository.DeleteAsync(fromCustomer); + if (deleteFromCustomer) + await _customerRepository.DeleteAsync(fromCustomer); } 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 5d60df721be..47b641dbed9 100644 --- a/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml +++ b/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml @@ -11910,6 +11910,9 @@ Requested merge customer does not exist, has been deleted or already merged. + + 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 ca97c236a94..17a948f1783 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs @@ -533,7 +533,7 @@ public virtual async Task MergeCustomerSearch(int? id, CustomerMe [HttpPost] [CheckPermission(StandardPermission.Customers.CUSTOMERS_CREATE_EDIT_DELETE)] - public virtual async Task Merge(int fromId, int toId, bool fromIsSource) + public virtual async Task Merge(int fromId, int toId, bool fromIsSource, bool deleteMergedCustomer) { if (await _customerService.GetCustomerByIdAsync(fromId) is Customer fromCustomer && !fromCustomer.Deleted && @@ -543,12 +543,12 @@ await _customerService.GetCustomerByIdAsync(toId) is Customer toCustomer && int resultId; if (fromIsSource) { - await _customerService.MergeCustomersAsync(fromCustomer, toCustomer); + await _customerService.MergeCustomersAsync(fromCustomer, toCustomer, deleteMergedCustomer); resultId = toCustomer.Id; } else { - await _customerService.MergeCustomersAsync(toCustomer, fromCustomer); + await _customerService.MergeCustomersAsync(toCustomer, fromCustomer, deleteMergedCustomer); resultId = fromCustomer.Id; } return RedirectToAction("Edit", new { id = resultId }); @@ -948,7 +948,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); } @@ -1596,7 +1596,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 index 9f4d791ae96..188d2c7b7b2 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs @@ -1,7 +1,12 @@ -namespace Nop.Web.Areas.Admin.Models.Customers; +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; } + + [NopResourceDisplayName("Admin.Customers.CustomerMerge.Fields.DeleteMergedCustomer")] + public bool DeleteMergedCustomer { get; set; } = true; } \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml index ba50edb96a6..939f728bf90 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml +++ b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml @@ -16,12 +16,6 @@ @T("Admin.Customers.Customers.Addresses.BackToCustomer") -
- -
@@ -51,6 +45,25 @@ + +
+
+ +
+
+ +
+ +
+
+
+
+ +
+
From 9ae12d580988e77b6e65ab5e4c5de2b09c2ed038 Mon Sep 17 00:00:00 2001 From: Dan Fehrenbach Date: Fri, 6 Jun 2025 12:21:17 -0500 Subject: [PATCH 4/5] moved merge parameters into model --- .../Admin/Controllers/CustomerController.cs | 17 ++++++----- .../Models/Customers/CustomerMergeModel.cs | 29 +++++++++++++++++-- .../Areas/Admin/Views/Customer/Merge.cshtml | 26 ++++++++--------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs index 17a948f1783..fc21b4364bb 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs @@ -498,7 +498,7 @@ public virtual async Task Merge(int id) { var customerModel = await _customerModelFactory.PrepareCustomerModelAsync(new CustomerModel(), customer); customerModel.FullName = await _customerService.GetCustomerFullNameAsync(customer); - return View(new CustomerMergeModel() { FromCustomer = customerModel, CurrentCustomerId = customer.Id }); + return View(new CustomerMergeModel(customerModel)); } //should be locale resource @@ -533,22 +533,23 @@ public virtual async Task MergeCustomerSearch(int? id, CustomerMe [HttpPost] [CheckPermission(StandardPermission.Customers.CUSTOMERS_CREATE_EDIT_DELETE)] - public virtual async Task Merge(int fromId, int toId, bool fromIsSource, bool deleteMergedCustomer) + public virtual async Task Merge(CustomerMergeModel model) { - if (await _customerService.GetCustomerByIdAsync(fromId) is Customer fromCustomer && + var mergeModel = model.Merge; + if (await _customerService.GetCustomerByIdAsync(mergeModel.FromId) is Customer fromCustomer && !fromCustomer.Deleted && - await _customerService.GetCustomerByIdAsync(toId) is Customer toCustomer && + await _customerService.GetCustomerByIdAsync(mergeModel.ToId) is Customer toCustomer && !toCustomer.Deleted) { int resultId; - if (fromIsSource) + if (mergeModel.FromIsSource) { - await _customerService.MergeCustomersAsync(fromCustomer, toCustomer, deleteMergedCustomer); + await _customerService.MergeCustomersAsync(fromCustomer, toCustomer, mergeModel.DeleteMergedCustomer); resultId = toCustomer.Id; } else { - await _customerService.MergeCustomersAsync(toCustomer, fromCustomer, deleteMergedCustomer); + await _customerService.MergeCustomersAsync(toCustomer, fromCustomer, mergeModel.DeleteMergedCustomer); resultId = fromCustomer.Id; } return RedirectToAction("Edit", new { id = resultId }); @@ -557,7 +558,7 @@ await _customerService.GetCustomerByIdAsync(toId) is Customer toCustomer && { //should be locale resource _notificationService.WarningNotification(await _localizationService.GetResourceAsync("Admin.Customers.Customers.MergeCustomerError")); - return RedirectToAction("Merge", new { id = fromId }); + return RedirectToAction("Merge", new { id = mergeModel.FromId }); } } diff --git a/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs b/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs index 188d2c7b7b2..b8466423ec6 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Models/Customers/CustomerMergeModel.cs @@ -7,6 +7,31 @@ public partial record CustomerMergeModel : CustomerSearchModel public CustomerModel FromCustomer { get; set; } public int CurrentCustomerId { get; set; } - [NopResourceDisplayName("Admin.Customers.CustomerMerge.Fields.DeleteMergedCustomer")] - public bool DeleteMergedCustomer { get; set; } = true; + 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/Merge.cshtml b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml index 939f728bf90..fe7218b1926 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml +++ b/src/Presentation/Nop.Web/Areas/Admin/Views/Customer/Merge.cshtml @@ -27,31 +27,31 @@
- +
-
- - + +
- +
- +
@@ -229,8 +229,8 @@ url: "@(Url.Action("MergeCustomerSearch", "Customer"))", data: postData, success: function (data, textStatus, jqXHR) { - document.getElementById("toId").value = data.id; - document.getElementById("to-customer-text").value = data.email + " (" + data.fullName + ")"; + document.getElementById("Merge_ToId").value = data.id; + document.getElementById("Merge_CustomerText").value = data.email + " (" + data.fullName + ")"; }, error: function (jqXHR, textStatus, errorThrown) { alert("failure"); @@ -238,12 +238,12 @@ }); } - var elems = document.querySelectorAll('input[name=fromIsSource]'); + var elems = document.querySelectorAll('input[name="Merge.FromIsSource"]'); for (let elem of elems) { console.log(elem); if (elem instanceof HTMLInputElement) { elem.addEventListener('change', () => { - var elems = document.querySelectorAll('input[name=fromIsSource]'); + var elems = document.querySelectorAll('input[name="Merge.FromIsSource"]'); for (let elem of elems) { if (elem.checked) { elem.parentElement.classList.remove('btn-outline-primary'); From a3bb09cf1fb4c688d1a91b8e6e522eaaed2ed51b Mon Sep 17 00:00:00 2001 From: Dan Fehrenbach Date: Fri, 6 Jun 2025 12:27:47 -0500 Subject: [PATCH 5/5] prevent merging of same customer --- .../App_Data/Localization/defaultResources.nopres.xml | 3 +++ .../Nop.Web/Areas/Admin/Controllers/CustomerController.cs | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 47b641dbed9..7e90f3e44c4 100644 --- a/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml +++ b/src/Presentation/Nop.Web/App_Data/Localization/defaultResources.nopres.xml @@ -11910,6 +11910,9 @@ Requested merge customer does not exist, has been deleted or already merged. + + The same customer cannot be merged into itself. + Delete customer after Merge diff --git a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs index fc21b4364bb..bdd1c6f2b12 100644 --- a/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Controllers/CustomerController.cs @@ -501,7 +501,6 @@ public virtual async Task Merge(int id) return View(new CustomerMergeModel(customerModel)); } - //should be locale resource _notificationService.WarningNotification(await _localizationService.GetResourceAsync("Admin.Customers.Customers.MergeCustomerError")); return RedirectToAction("List"); } @@ -541,6 +540,13 @@ public virtual async Task Merge(CustomerMergeModel model) 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) {