From 824177c3cf199ff32b050f8c94035d139d07b7e0 Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Tue, 16 Dec 2025 20:35:08 +0200 Subject: [PATCH 1/7] VCST-4182: Order Document Count Validation feat: Order Document Count Validation. Default 20. feat: Enable Order Validation. --- .../ModuleConstants.cs | 11 +- .../Services/CustomerOrderPaymentService.cs | 12 +- .../Services/CustomerOrderService.cs | 470 ++++++++------- .../Extensions/ServiceCollectionExtensions.cs | 5 + .../de.VirtoCommerce.Orders.json | 4 + .../en.VirtoCommerce.Orders.json | 4 + .../es.VirtoCommerce.Orders.json | 4 + .../fr.VirtoCommerce.Orders.json | 4 + .../it.VirtoCommerce.Orders.json | 4 + .../ja.VirtoCommerce.Orders.json | 4 + .../pl.VirtoCommerce.Orders.json | 4 + .../pt.VirtoCommerce.Orders.json | 4 + .../ru.VirtoCommerce.Orders.json | 4 + .../zh.VirtoCommerce.Orders.json | 4 + .../Validation/CustomerOrderValidator.cs | 102 ++-- .../Validation/OrderDocumentCountValidator.cs | 111 ++++ ...ustomerOrderServiceImplIntegrationTests.cs | 25 + .../CustomerOrderServiceUnitTests.cs | 14 +- .../OrderDocumentCountValidatorTests.cs | 542 ++++++++++++++++++ 19 files changed, 1068 insertions(+), 264 deletions(-) create mode 100644 src/VirtoCommerce.OrdersModule.Web/Validation/OrderDocumentCountValidator.cs create mode 100644 tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs diff --git a/src/VirtoCommerce.OrdersModule.Core/ModuleConstants.cs b/src/VirtoCommerce.OrdersModule.Core/ModuleConstants.cs index ce0a17d2f..bd5253766 100644 --- a/src/VirtoCommerce.OrdersModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.OrdersModule.Core/ModuleConstants.cs @@ -232,7 +232,7 @@ public static class General Name = "Order.Validation.Enable", GroupName = "Orders|General", ValueType = SettingValueType.Boolean, - DefaultValue = false + DefaultValue = true }; public static SettingDescriptor OrderPaidAndOrderSentNotifications { get; } = new SettingDescriptor @@ -276,6 +276,14 @@ public static class General IsPublic = true, }; + public static SettingDescriptor MaxOrderDocumentCount { get; } = new SettingDescriptor + { + Name = "Order.MaxOrderDocumentCount", + GroupName = "Orders|General", + ValueType = SettingValueType.Integer, + DefaultValue = 20, + }; + public static IEnumerable AllSettings { get @@ -301,6 +309,7 @@ public static IEnumerable AllSettings yield return PurchasedProductIndexation; yield return EventBasedPurchasedProductIndexation; yield return PurchasedProductStoreFilter; + yield return MaxOrderDocumentCount; } } } diff --git a/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderPaymentService.cs b/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderPaymentService.cs index b7dba2b77..6c6b11c0f 100644 --- a/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderPaymentService.cs +++ b/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderPaymentService.cs @@ -21,8 +21,7 @@ public class CustomerOrderPaymentService( IStoreService storeService, ICustomerOrderService customerOrderService, ICustomerOrderSearchService customerOrderSearchService, - IValidator customerOrderValidator, - ISettingsManager settingsManager) + IValidator customerOrderValidator) : ICustomerOrderPaymentService { public virtual async Task PostProcessPaymentAsync(PaymentParameters paymentParameters) @@ -117,13 +116,8 @@ protected virtual IList GetInPayments(CustomerOrder customerOrder, Pa .ToList(); } - protected virtual async Task ValidateAsync(CustomerOrder customerOrder) + protected virtual Task ValidateAsync(CustomerOrder customerOrder) { - if (await settingsManager.GetValueAsync(ModuleConstants.Settings.General.CustomerOrderValidation)) - { - return await customerOrderValidator.ValidateAsync(customerOrder); - } - - return new ValidationResult(); + return customerOrderValidator.ValidateAsync(customerOrder); } } diff --git a/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs b/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs index b789b055b..611eb446f 100644 --- a/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs +++ b/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs @@ -2,10 +2,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using FluentValidation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using VirtoCommerce.AssetsModule.Core.Assets; using VirtoCommerce.CoreModule.Core.Common; +using VirtoCommerce.OrdersModule.Core; using VirtoCommerce.OrdersModule.Core.Events; using VirtoCommerce.OrdersModule.Core.Model; using VirtoCommerce.OrdersModule.Core.Services; @@ -25,290 +27,320 @@ using VirtoCommerce.StoreModule.Core.Model; using VirtoCommerce.StoreModule.Core.Services; -namespace VirtoCommerce.OrdersModule.Data.Services +namespace VirtoCommerce.OrdersModule.Data.Services; + +public class CustomerOrderService : OuterEntityService, ICustomerOrderService, IMemberOrdersService { - public class CustomerOrderService : OuterEntityService, ICustomerOrderService, IMemberOrdersService + private readonly Func _repositoryFactory; + private readonly IPlatformMemoryCache _platformMemoryCache; + private readonly IEventPublisher _eventPublisher; + private readonly ITenantUniqueNumberGenerator _uniqueNumberGenerator; + private readonly IStoreService _storeService; + private readonly ICustomerOrderTotalsCalculator _totalsCalculator; + private readonly IShippingMethodsSearchService _shippingMethodsSearchService; + private readonly IPaymentMethodsSearchService _paymentMethodSearchService; + private readonly IBlobUrlResolver _blobUrlResolver; + private readonly IValidator _customerOrderValidator; + + public CustomerOrderService( + Func repositoryFactory, + IPlatformMemoryCache platformMemoryCache, + IEventPublisher eventPublisher, + ITenantUniqueNumberGenerator uniqueNumberGenerator, + IStoreService storeService, + ICustomerOrderTotalsCalculator totalsCalculator, + IShippingMethodsSearchService shippingMethodsSearchService, + IPaymentMethodsSearchService paymentMethodSearchService, + IBlobUrlResolver blobUrlResolver, + IValidator customerOrderValidator) + : base(repositoryFactory, platformMemoryCache, eventPublisher) { - private readonly Func _repositoryFactory; - private readonly IPlatformMemoryCache _platformMemoryCache; - private readonly IEventPublisher _eventPublisher; - private readonly ITenantUniqueNumberGenerator _uniqueNumberGenerator; - private readonly IStoreService _storeService; - private readonly ICustomerOrderTotalsCalculator _totalsCalculator; - private readonly IShippingMethodsSearchService _shippingMethodsSearchService; - private readonly IPaymentMethodsSearchService _paymentMethodSearchService; - private readonly IBlobUrlResolver _blobUrlResolver; - - public CustomerOrderService( - Func repositoryFactory, - IPlatformMemoryCache platformMemoryCache, - IEventPublisher eventPublisher, - ITenantUniqueNumberGenerator uniqueNumberGenerator, - IStoreService storeService, - ICustomerOrderTotalsCalculator totalsCalculator, - IShippingMethodsSearchService shippingMethodsSearchService, - IPaymentMethodsSearchService paymentMethodSearchService, - IBlobUrlResolver blobUrlResolver) - : base(repositoryFactory, platformMemoryCache, eventPublisher) - { - _repositoryFactory = repositoryFactory; - _platformMemoryCache = platformMemoryCache; - _eventPublisher = eventPublisher; - _uniqueNumberGenerator = uniqueNumberGenerator; - _storeService = storeService; - _totalsCalculator = totalsCalculator; - _shippingMethodsSearchService = shippingMethodsSearchService; - _paymentMethodSearchService = paymentMethodSearchService; - _blobUrlResolver = blobUrlResolver; - } + _repositoryFactory = repositoryFactory; + _platformMemoryCache = platformMemoryCache; + _eventPublisher = eventPublisher; + _uniqueNumberGenerator = uniqueNumberGenerator; + _storeService = storeService; + _totalsCalculator = totalsCalculator; + _shippingMethodsSearchService = shippingMethodsSearchService; + _paymentMethodSearchService = paymentMethodSearchService; + _blobUrlResolver = blobUrlResolver; + _customerOrderValidator = customerOrderValidator; + } - public override async Task SaveChangesAsync(IList models) - { - var pkMap = new PrimaryKeyResolvingMap(); - var changedEntries = new List>(); - var changedEntities = new List(); + protected override async Task BeforeSaveChanges(IList models) + { + await ValidateOrdersAsync(models); - await BeforeSaveChanges(models); + await base.BeforeSaveChanges(models); + } - using (var repository = _repositoryFactory()) - { - var orderIds = models.Where(x => !x.IsTransient()).Select(x => x.Id).ToArray(); - var existingEntities = await repository.GetCustomerOrdersByIdsAsync(orderIds, CustomerOrderResponseGroup.Full.ToString()); - foreach (var modifiedOrder in models) - { - await EnsureThatAllOperationsHaveNumber(modifiedOrder); - await LoadOrderDependenciesAsync(modifiedOrder); + public override async Task SaveChangesAsync(IList models) + { + var pkMap = new PrimaryKeyResolvingMap(); + var changedEntries = new List>(); + var changedEntities = new List(); - var originalEntity = existingEntities?.FirstOrDefault(x => x.Id == modifiedOrder.Id); + await BeforeSaveChanges(models); - if (originalEntity != null) - { - // Patch RowVersion to throw concurrency exception if someone updated order before - // https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations#optimistic-concurrency - // https://stackoverflow.com/questions/75454812/entity-framework-core-manually-changing-rowversion-of-entity-has-no-effect-on-c - repository.PatchRowVersion(originalEntity, modifiedOrder.RowVersion); - - var oldModel = originalEntity.ToModel(AbstractTypeFactory.TryCreateInstance()); - await LoadOrderDependenciesAsync(oldModel); - _totalsCalculator.CalculateTotals(oldModel); - - // Workaround to trigger update of auditable fields when only updating navigation properties. - // Otherwise on update trigger is fired only when non navigation properties are updated. - originalEntity.ModifiedDate = DateTime.UtcNow; - - var modifiedEntity = AbstractTypeFactory.TryCreateInstance().FromModel(modifiedOrder, pkMap); - // This extension is allow to get around breaking changes is introduced in EF Core 3.0 that leads to throw - // Database operation expected to affect 1 row(s) but actually affected 0 row(s) exception when trying to add the new children entities with manually set keys - // https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-3.0/breaking-changes#detectchanges-honors-store-generated-key-values - repository.TrackModifiedAsAddedForNewChildEntities(originalEntity); - - changedEntries.Add(new GenericChangedEntry(modifiedOrder, oldModel, EntryState.Modified)); - modifiedEntity?.Patch(originalEntity); - - //originalEntity is fully loaded and contains changes from order - var newModel = originalEntity.ToModel(AbstractTypeFactory.TryCreateInstance()); - - //newModel is fully loaded,so we can CalculateTotals for order - _totalsCalculator.CalculateTotals(newModel); - //Double convert and patch are required, because of partial order update when some properties are used in totals calculation are missed - var newModifiedEntity = AbstractTypeFactory.TryCreateInstance().FromModel(newModel, pkMap); - newModifiedEntity?.Patch(originalEntity); - changedEntities.Add(originalEntity); - } - else - { - _totalsCalculator.CalculateTotals(modifiedOrder); - var modifiedEntity = AbstractTypeFactory.TryCreateInstance().FromModel(modifiedOrder, pkMap); - repository.Add(modifiedEntity); - changedEntries.Add(new GenericChangedEntry(modifiedOrder, EntryState.Added)); - changedEntities.Add(modifiedEntity); - } - } + using (var repository = _repositoryFactory()) + { + var orderIds = models.Where(x => !x.IsTransient()).Select(x => x.Id).ToArray(); + var existingEntities = await repository.GetCustomerOrdersByIdsAsync(orderIds, CustomerOrderResponseGroup.Full.ToString()); + foreach (var modifiedOrder in models) + { + await EnsureThatAllOperationsHaveNumber(modifiedOrder); + await LoadOrderDependenciesAsync(modifiedOrder); - //Raise domain events - await _eventPublisher.Publish(new OrderChangeEvent(changedEntries)); + var originalEntity = existingEntities?.FirstOrDefault(x => x.Id == modifiedOrder.Id); - try + if (originalEntity != null) { - await repository.UnitOfWork.CommitAsync(); + // Patch RowVersion to throw concurrency exception if someone updated order before + // https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations#optimistic-concurrency + // https://stackoverflow.com/questions/75454812/entity-framework-core-manually-changing-rowversion-of-entity-has-no-effect-on-c + repository.PatchRowVersion(originalEntity, modifiedOrder.RowVersion); + + var oldModel = originalEntity.ToModel(AbstractTypeFactory.TryCreateInstance()); + await LoadOrderDependenciesAsync(oldModel); + _totalsCalculator.CalculateTotals(oldModel); + + // Workaround to trigger update of auditable fields when only updating navigation properties. + // Otherwise on update trigger is fired only when non navigation properties are updated. + originalEntity.ModifiedDate = DateTime.UtcNow; + + var modifiedEntity = AbstractTypeFactory.TryCreateInstance().FromModel(modifiedOrder, pkMap); + // This extension is allow to get around breaking changes is introduced in EF Core 3.0 that leads to throw + // Database operation expected to affect 1 row(s) but actually affected 0 row(s) exception when trying to add the new children entities with manually set keys + // https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-3.0/breaking-changes#detectchanges-honors-store-generated-key-values + repository.TrackModifiedAsAddedForNewChildEntities(originalEntity); + + changedEntries.Add(new GenericChangedEntry(modifiedOrder, oldModel, EntryState.Modified)); + modifiedEntity?.Patch(originalEntity); + + //originalEntity is fully loaded and contains changes from order + var newModel = originalEntity.ToModel(AbstractTypeFactory.TryCreateInstance()); + + //newModel is fully loaded,so we can CalculateTotals for order + _totalsCalculator.CalculateTotals(newModel); + //Double convert and patch are required, because of partial order update when some properties are used in totals calculation are missed + var newModifiedEntity = AbstractTypeFactory.TryCreateInstance().FromModel(newModel, pkMap); + newModifiedEntity?.Patch(originalEntity); + changedEntities.Add(originalEntity); } - catch (DbUpdateConcurrencyException) + else { - throw new DbUpdateConcurrencyException("The order has been modified by another user. Please reload the latest data and try again."); + _totalsCalculator.CalculateTotals(modifiedOrder); + var modifiedEntity = AbstractTypeFactory.TryCreateInstance().FromModel(modifiedOrder, pkMap); + repository.Add(modifiedEntity); + changedEntries.Add(new GenericChangedEntry(modifiedOrder, EntryState.Added)); + changedEntities.Add(modifiedEntity); } - finally - { - ClearCache(models); - } - - pkMap.ResolvePrimaryKeys(); } - // VP-5561: Need to fill changedEntries newEntry with the models built from saved entities (with the info filled when saving to database) - foreach (var (changedEntry, i) in changedEntries.Select((x, i) => (x, i))) + //Raise domain events + await _eventPublisher.Publish(new OrderChangeEvent(changedEntries)); + + try + { + await repository.UnitOfWork.CommitAsync(); + } + catch (DbUpdateConcurrencyException) + { + throw new DbUpdateConcurrencyException("The order has been modified by another user. Please reload the latest data and try again."); + } + finally { - // Here the original model from models parameter - var changedModel = changedEntities[i].ToModel(changedEntry.NewEntry); + ClearCache(models); + } - // We need to CalculateTotals for the new Order, because it is empty after entity.ToModel creation - _totalsCalculator.CalculateTotals(changedModel); + pkMap.ResolvePrimaryKeys(); + } - await LoadOrderDependenciesAsync(changedModel); - changedEntry.NewEntry = changedModel; - } + // VP-5561: Need to fill changedEntries newEntry with the models built from saved entities (with the info filled when saving to database) + foreach (var (changedEntry, i) in changedEntries.Select((x, i) => (x, i))) + { + // Here the original model from models parameter + var changedModel = changedEntities[i].ToModel(changedEntry.NewEntry); - await AfterSaveChangesAsync(models, changedEntries); + // We need to CalculateTotals for the new Order, because it is empty after entity.ToModel creation + _totalsCalculator.CalculateTotals(changedModel); - await _eventPublisher.Publish(new OrderChangedEvent(changedEntries)); + await LoadOrderDependenciesAsync(changedModel); + changedEntry.NewEntry = changedModel; } - public override async Task DeleteAsync(IList ids, bool softDelete = false) + await AfterSaveChangesAsync(models, changedEntries); + + await _eventPublisher.Publish(new OrderChangedEvent(changedEntries)); + } + + public override async Task DeleteAsync(IList ids, bool softDelete = false) + { + var orders = await GetAsync(ids, CustomerOrderResponseGroup.Full.ToString()); + using (var repository = _repositoryFactory()) { - var orders = await GetAsync(ids, CustomerOrderResponseGroup.Full.ToString()); - using (var repository = _repositoryFactory()) - { - //Raise domain events before deletion - var changedEntries = orders.Select(x => new GenericChangedEntry(x, EntryState.Deleted)).ToList(); - await _eventPublisher.Publish(new OrderChangeEvent(changedEntries)); + //Raise domain events before deletion + var changedEntries = orders.Select(x => new GenericChangedEntry(x, EntryState.Deleted)).ToList(); + await _eventPublisher.Publish(new OrderChangeEvent(changedEntries)); - await repository.RemoveOrdersByIdsAsync(ids); + await repository.RemoveOrdersByIdsAsync(ids); - await repository.UnitOfWork.CommitAsync(); + await repository.UnitOfWork.CommitAsync(); - ClearCache(orders); - //Raise domain events after deletion - await _eventPublisher.Publish(new OrderChangedEvent(changedEntries)); - } + ClearCache(orders); + //Raise domain events after deletion + await _eventPublisher.Publish(new OrderChangedEvent(changedEntries)); } + } - public virtual bool IsFirstTimeBuyer(string customerId) + public virtual bool IsFirstTimeBuyer(string customerId) + { + var cacheKey = CacheKey.With(GetType(), nameof(IsFirstTimeBuyer), customerId); + var result = _platformMemoryCache.GetOrCreateExclusive(cacheKey, cacheEntry => { - var cacheKey = CacheKey.With(GetType(), nameof(IsFirstTimeBuyer), customerId); - var result = _platformMemoryCache.GetOrCreateExclusive(cacheKey, cacheEntry => - { - cacheEntry.AddExpirationToken(CreateCacheToken(customerId)); + cacheEntry.AddExpirationToken(CreateCacheToken(customerId)); - using var repository = _repositoryFactory(); - return !repository.CustomerOrders.Any(x => x.CustomerId == customerId); - }); + using var repository = _repositoryFactory(); + return !repository.CustomerOrders.Any(x => x.CustomerId == customerId); + }); - return result; - } + return result; + } - protected virtual async Task LoadOrderDependenciesAsync(CustomerOrder order) + protected virtual async Task LoadOrderDependenciesAsync(CustomerOrder order) + { + if (order == null) { - if (order == null) - { - throw new ArgumentNullException(nameof(order)); - } + throw new ArgumentNullException(nameof(order)); + } - var searchShippingMethodsTask = _shippingMethodsSearchService.SearchAsync(new ShippingMethodsSearchCriteria { StoreId = order.StoreId }); - var searchPaymentMethodsTask = _paymentMethodSearchService.SearchAsync(new PaymentMethodsSearchCriteria { StoreId = order.StoreId }); + var searchShippingMethodsTask = _shippingMethodsSearchService.SearchAsync(new ShippingMethodsSearchCriteria { StoreId = order.StoreId }); + var searchPaymentMethodsTask = _paymentMethodSearchService.SearchAsync(new PaymentMethodsSearchCriteria { StoreId = order.StoreId }); - await Task.WhenAll(searchShippingMethodsTask, searchPaymentMethodsTask); - if (!searchShippingMethodsTask.Result.Results.IsNullOrEmpty() && !order.Shipments.IsNullOrEmpty()) + await Task.WhenAll(searchShippingMethodsTask, searchPaymentMethodsTask); + if (!searchShippingMethodsTask.Result.Results.IsNullOrEmpty() && !order.Shipments.IsNullOrEmpty()) + { + foreach (var shipment in order.Shipments) { - foreach (var shipment in order.Shipments) - { - shipment.ShippingMethod = searchShippingMethodsTask.Result.Results.FirstOrDefault(x => x.Code.EqualsIgnoreCase(shipment.ShipmentMethodCode)); - } + shipment.ShippingMethod = searchShippingMethodsTask.Result.Results.FirstOrDefault(x => x.Code.EqualsIgnoreCase(shipment.ShipmentMethodCode)); } - if (!searchPaymentMethodsTask.Result.Results.IsNullOrEmpty() && !order.InPayments.IsNullOrEmpty()) + } + if (!searchPaymentMethodsTask.Result.Results.IsNullOrEmpty() && !order.InPayments.IsNullOrEmpty()) + { + foreach (var payment in order.InPayments) { - foreach (var payment in order.InPayments) - { - payment.PaymentMethod = searchPaymentMethodsTask.Result.Results.FirstOrDefault(x => x.Code.EqualsIgnoreCase(payment.GatewayCode)); - } + payment.PaymentMethod = searchPaymentMethodsTask.Result.Results.FirstOrDefault(x => x.Code.EqualsIgnoreCase(payment.GatewayCode)); } } + } - protected virtual async Task EnsureThatAllOperationsHaveNumber(CustomerOrder order) - { - var store = await _storeService.GetNoCloneAsync(order.StoreId, StoreResponseGroup.StoreInfo.ToString()); + protected virtual async Task EnsureThatAllOperationsHaveNumber(CustomerOrder order) + { + var store = await _storeService.GetNoCloneAsync(order.StoreId, StoreResponseGroup.StoreInfo.ToString()); - foreach (var operation in order.GetFlatObjectsListWithInterface()) + foreach (var operation in order.GetFlatObjectsListWithInterface()) + { + if (operation.Number == null) { - if (operation.Number == null) - { - var objectTypeName = operation.OperationType; + var objectTypeName = operation.OperationType; - // take uppercase chars to form operation type, or just take 2 first chars. (CustomerOrder => CO, PaymentIn => PI, Shipment => SH) - var opType = string.Concat(objectTypeName.Select(c => char.IsUpper(c) ? c.ToString() : "")); - if (opType.Length < 2) - { - opType = objectTypeName.Substring(0, 2).ToUpper(); - } + // take uppercase chars to form operation type, or just take 2 first chars. (CustomerOrder => CO, PaymentIn => PI, Shipment => SH) + var opType = string.Concat(objectTypeName.Select(c => char.IsUpper(c) ? c.ToString() : "")); + if (opType.Length < 2) + { + opType = objectTypeName.Substring(0, 2).ToUpper(); + } - var numberTemplate = opType + "{0:yyMMdd}-{1:D5}"; - if (store != null) + var numberTemplate = opType + "{0:yyMMdd}-{1:D5}"; + if (store != null) + { + var descriptor = new SettingDescriptor { - var descriptor = new SettingDescriptor - { - Name = "Order." + objectTypeName + "NewNumberTemplate", - DefaultValue = numberTemplate, - }; - numberTemplate = store.Settings.GetValue(descriptor); - } - - operation.Number = _uniqueNumberGenerator.GenerateNumber(order.StoreId, numberTemplate); + Name = "Order." + objectTypeName + "NewNumberTemplate", + DefaultValue = numberTemplate, + }; + numberTemplate = store.Settings.GetValue(descriptor); } + + operation.Number = _uniqueNumberGenerator.GenerateNumber(order.StoreId, numberTemplate); } } + } - protected override Task> LoadEntities(IRepository repository, IList ids, string responseGroup) - { - return ((IOrderRepository)repository).GetCustomerOrdersByIdsAsync(ids, responseGroup); - } + protected override Task> LoadEntities(IRepository repository, IList ids, string responseGroup) + { + return ((IOrderRepository)repository).GetCustomerOrdersByIdsAsync(ids, responseGroup); + } - protected override IQueryable GetEntitiesQuery(IRepository repository) + protected override IQueryable GetEntitiesQuery(IRepository repository) + { + return ((IOrderRepository)repository).CustomerOrders; + } + + protected override CustomerOrder ProcessModel(string responseGroup, CustomerOrderEntity entity, CustomerOrder model) + { + var orderResponseGroup = EnumUtility.SafeParseFlags(responseGroup, CustomerOrderResponseGroup.Full); + + //Calculate totals only for full responseGroup + if (orderResponseGroup == CustomerOrderResponseGroup.Full) { - return ((IOrderRepository)repository).CustomerOrders; + _totalsCalculator.CalculateTotals(model); } - protected override CustomerOrder ProcessModel(string responseGroup, CustomerOrderEntity entity, CustomerOrder model) - { - var orderResponseGroup = EnumUtility.SafeParseFlags(responseGroup, CustomerOrderResponseGroup.Full); + LoadOrderDependenciesAsync(model).GetAwaiter().GetResult(); + model.ReduceDetails(responseGroup); + ResolveFileUrls(model); + return model; + } - //Calculate totals only for full responseGroup - if (orderResponseGroup == CustomerOrderResponseGroup.Full) - { - _totalsCalculator.CalculateTotals(model); - } + protected override void ClearCache(IList models) + { + GenericSearchCachingRegion.ExpireRegion(); - LoadOrderDependenciesAsync(model).GetAwaiter().GetResult(); - model.ReduceDetails(responseGroup); - ResolveFileUrls(model); - return model; + foreach (var model in models) + { + GenericCachingRegion.ExpireTokenForKey(model.Id); + GenericCachingRegion.ExpireTokenForKey(model.CustomerId); } + } - protected override void ClearCache(IList models) + private void ResolveFileUrls(CustomerOrder order) + { + if (order.Items is null) { - GenericSearchCachingRegion.ExpireRegion(); - - foreach (var model in models) - { - GenericCachingRegion.ExpireTokenForKey(model.Id); - GenericCachingRegion.ExpireTokenForKey(model.CustomerId); - } + return; } - private void ResolveFileUrls(CustomerOrder order) + var files = order.Items + .Where(i => i.ConfigurationItems != null) + .SelectMany(i => i.ConfigurationItems + .Where(c => c.Files != null) + .SelectMany(c => c.Files + .Where(f => !string.IsNullOrEmpty(f.Url)))); + + foreach (var file in files) { - if (order.Items is null) - { - return; - } + file.Url = file.Url.StartsWith("/api") ? file.Url : _blobUrlResolver.GetAbsoluteUrl(file.Url); + } + } - var files = order.Items - .Where(i => i.ConfigurationItems != null) - .SelectMany(i => i.ConfigurationItems - .Where(c => c.Files != null) - .SelectMany(c => c.Files - .Where(f => !string.IsNullOrEmpty(f.Url)))); + /// + /// Validates customer orders if validation is enabled in settings + /// + /// Orders to validate + /// Thrown when validation fails + protected virtual async Task ValidateOrdersAsync(IList orders) + { + foreach (var order in orders) + { + var validationResult = await _customerOrderValidator.ValidateAsync(order); - foreach (var file in files) + if (!validationResult.IsValid) { - file.Url = file.Url.StartsWith("/api") ? file.Url : _blobUrlResolver.GetAbsoluteUrl(file.Url); + var errorMessages = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + throw new ValidationException( + $"Order '{order.Number}' validation failed: {errorMessages}", + validationResult.Errors); } } } diff --git a/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs b/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs index 18f359079..665f431c6 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using FluentValidation; using Microsoft.Extensions.DependencyInjection; +using VirtoCommerce.CoreModule.Core.Common; using VirtoCommerce.OrdersModule.Core.Model; using VirtoCommerce.OrdersModule.Data.Validators; using VirtoCommerce.OrdersModule.Web.Validation; @@ -10,6 +11,10 @@ public static class ServiceCollectionExtensions { public static void AddValidators(this IServiceCollection serviceCollection) { + // Register operation-level validators + serviceCollection.AddTransient, OrderDocumentCountValidator>(); + + // Register entity-specific validators serviceCollection.AddTransient, CustomerOrderValidator>(); serviceCollection.AddTransient, PaymentInValidator>(); serviceCollection.AddTransient, PaymentRequestValidator>(); diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/de.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/de.VirtoCommerce.Orders.json index 0e7130cca..11c69bf9f 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/de.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/de.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Aktivierung des Store-Filters für gekaufte Produkte", "description": "Zeigt den Filter für gekaufte Produkte im Store an." + }, + "Order.MaxOrderDocumentCount": { + "title": "Maximale Anzahl von Unterdokumenten pro Bestellung", + "description": "Definiert die maximale Anzahl von Unterdokumenten (Zahlungen, Sendungen, Captures, Rückerstattungen usw.), die pro Bestellung erstellt oder gespeichert werden können. Dies gewährleistet Systemleistung, Speicheroptimierung und Datenkonsistenz. Bei Überschreitung dieser Grenze wird beim Speichern eine Ausnahme ausgelöst" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/en.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/en.VirtoCommerce.Orders.json index d063d305f..7cf27bc97 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/en.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/en.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Enable purchased product store filter", "description": "Display the purchased product filter for the store" + }, + "Order.MaxOrderDocumentCount": { + "title": "Maximum number of child documents per order", + "description": "Defines the maximum number of child documents (payments, shipments, captures, refunds, etc.) that can be created or stored per order. This ensures system performance, storage optimization, and data consistency. An exception will be thrown on save if this limit is exceeded" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/es.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/es.VirtoCommerce.Orders.json index 9088dd3e8..e9580f08a 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/es.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/es.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Habilitar el filtro de productos comprados en la tienda", "description": "Muestra el filtro de productos comprados en la tienda." + }, + "Order.MaxOrderDocumentCount": { + "title": "Número máximo de documentos secundarios por pedido", + "description": "Define el número máximo de documentos secundarios (pagos, envíos, capturas, reembolsos, etc.) que se pueden crear o almacenar por pedido. Esto garantiza el rendimiento del sistema, la optimización del almacenamiento y la consistencia de los datos. Se generará una excepción al guardar si se excede este límite" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/fr.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/fr.VirtoCommerce.Orders.json index c2962b126..07db9d4f6 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/fr.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/fr.VirtoCommerce.Orders.json @@ -619,6 +619,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Activer le filtre de produits achetés en magasin", "description": "Affiche le filtre de produits achetés pour le magasin." + }, + "Order.MaxOrderDocumentCount": { + "title": "Nombre maximum de documents enfants par commande", + "description": "Définit le nombre maximum de documents enfants (paiements, expéditions, captures, remboursements, etc.) qui peuvent être créés ou stockés par commande. Cela garantit les performances du système, l'optimisation du stockage et la cohérence des données. Une exception sera levée lors de la sauvegarde si cette limite est dépassée" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/it.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/it.VirtoCommerce.Orders.json index a6920c1c9..899f6cfd3 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/it.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/it.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Abilita il filtro dei prodotti acquistati nel negozio", "description": "Visualizza il filtro dei prodotti acquistati per il negozio." + }, + "Order.MaxOrderDocumentCount": { + "title": "Numero massimo di documenti secondari per ordine", + "description": "Definisce il numero massimo di documenti secondari (pagamenti, spedizioni, acquisizioni, rimborsi, ecc.) che possono essere creati o memorizzati per ordine. Ciò garantisce le prestazioni del sistema, l'ottimizzazione dello storage e la coerenza dei dati. Verrà generata un'eccezione durante il salvataggio se questo limite viene superato" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/ja.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/ja.VirtoCommerce.Orders.json index 44b6b0765..93a3d0712 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/ja.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/ja.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "購入済み商品ストアフィルターを有効にする", "description": "ストアで購入済み商品フィルターを表示します。" + }, + "Order.MaxOrderDocumentCount": { + "title": "注文あたりの子ドキュメントの最大数", + "description": "注文ごとに作成または保存できる子ドキュメント(支払い、配送、キャプチャ、返金など)の最大数を定義します。これにより、システムパフォーマンス、ストレージの最適化、データの整合性が確保されます。この制限を超えた場合、保存時に例外がスローされます" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/pl.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/pl.VirtoCommerce.Orders.json index 27eb84052..15e457a0c 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/pl.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/pl.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Włącz filtr zakupionych produktów w sklepie", "description": "Wyświetla filtr zakupionych produktów w sklepie." + }, + "Order.MaxOrderDocumentCount": { + "title": "Maksymalna liczba dokumentów podrzędnych na zamówienie", + "description": "Określa maksymalną liczbę dokumentów podrzędnych (płatności, przesyłki, przechwycenia, zwroty itp.), które można utworzyć lub przechowywać dla jednego zamówienia. Zapewnia to wydajność systemu, optymalizację przechowywania i spójność danych. Jeśli ten limit zostanie przekroczony, podczas zapisywania zostanie zgłoszony wyjątek" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/pt.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/pt.VirtoCommerce.Orders.json index 36273bba2..36f4ae507 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/pt.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/pt.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Ativar filtro de produtos comprados na loja", "description": "Exibe o filtro de produtos comprados para a loja." + }, + "Order.MaxOrderDocumentCount": { + "title": "Número máximo de documentos secundários por pedido", + "description": "Define o número máximo de documentos secundários (pagamentos, remessas, capturas, reembolsos, etc.) que podem ser criados ou armazenados por pedido. Isso garante o desempenho do sistema, otimização de armazenamento e consistência de dados. Uma exceção será lançada ao salvar se este limite for excedido" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/ru.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/ru.VirtoCommerce.Orders.json index 082910e2f..c079e6391 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/ru.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/ru.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "Включить фильтр приобретенных продуктов в магазине", "description": "Отображает фильтр приобретенных продуктов для магазина" + }, + "Order.MaxOrderDocumentCount": { + "title": "Максимальное количество дочерних документов на заказ", + "description": "Определяет максимальное количество дочерних документов (платежи, отгрузки, захваты, возвраты и т.д.), которые могут быть созданы или сохранены для одного заказа. Это обеспечивает производительность системы, оптимизацию хранилища и согласованность данных. При превышении этого лимита при сохранении будет выброшено исключение" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Localizations/zh.VirtoCommerce.Orders.json b/src/VirtoCommerce.OrdersModule.Web/Localizations/zh.VirtoCommerce.Orders.json index 25e65c82a..63c4593f1 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Localizations/zh.VirtoCommerce.Orders.json +++ b/src/VirtoCommerce.OrdersModule.Web/Localizations/zh.VirtoCommerce.Orders.json @@ -618,6 +618,10 @@ "Order.PurchasedProductStoreFilter.Enable": { "title": "启用已购买产品的商店过滤器", "description": "在商店中显示已购买产品的过滤器。" + }, + "Order.MaxOrderDocumentCount": { + "title": "每个订单的最大子文档数量", + "description": "定义每个订单可以创建或存储的子文档(付款、发货、捕获、退款等)的最大数量。这确保了系统性能、存储优化和数据一致性。如果超过此限制,保存时将抛出异常" } }, "module": { diff --git a/src/VirtoCommerce.OrdersModule.Web/Validation/CustomerOrderValidator.cs b/src/VirtoCommerce.OrdersModule.Web/Validation/CustomerOrderValidator.cs index 77f522456..59be9197c 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Validation/CustomerOrderValidator.cs +++ b/src/VirtoCommerce.OrdersModule.Web/Validation/CustomerOrderValidator.cs @@ -1,51 +1,85 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using FluentValidation; +using FluentValidation.Results; +using VirtoCommerce.CoreModule.Core.Common; +using VirtoCommerce.OrdersModule.Core; using VirtoCommerce.OrdersModule.Core.Model; +using VirtoCommerce.Platform.Core.Settings; -namespace VirtoCommerce.OrdersModule.Web.Validation +namespace VirtoCommerce.OrdersModule.Web.Validation; + +public class CustomerOrderValidator : AbstractValidator { - public class CustomerOrderValidator : AbstractValidator + private readonly ISettingsManager _settingsManager; + + public CustomerOrderValidator( + ISettingsManager settingsManager, + IEnumerable> lineItemValidators, + IEnumerable> shipmentValidators, + IValidator paymentInValidator, + IEnumerable> operationValidators) { - public CustomerOrderValidator(IEnumerable> lineItemValidators, - IEnumerable> shipmentValidators, - IValidator paymentInValidator) + _settingsManager = settingsManager; + + SetDefaultRules(); + + if (lineItemValidators.Any()) { - SetDefaultRules(); + RuleForEach(order => order.Items).SetValidator(lineItemValidators.Last(), "default"); + } - if (lineItemValidators.Any()) - { - RuleForEach(order => order.Items).SetValidator(lineItemValidators.Last(), "default"); - } + if (shipmentValidators.Any()) + { + RuleForEach(order => order.Shipments).SetValidator(shipmentValidators.Last(), "default"); + } - if (shipmentValidators.Any()) - { - RuleForEach(order => order.Shipments).SetValidator(shipmentValidators.Last(), "default"); - } + RuleForEach(order => order.InPayments).SetValidator(paymentInValidator); - RuleForEach(order => order.InPayments).SetValidator(paymentInValidator); + // Apply all operation-level validators (e.g., document count limits) + foreach (var operationValidator in operationValidators) + { + Include(operationValidator); } - protected void SetDefaultRules() + } + + public override async Task ValidateAsync(ValidationContext context, CancellationToken cancellation = default) + { + // Check if validation is enabled + var isValidationEnabled = await _settingsManager.GetValueAsync( + ModuleConstants.Settings.General.CustomerOrderValidation); + + if (!isValidationEnabled) { + // Skip validation if disabled + return new ValidationResult(); + } + + // Perform validation if enabled + return await base.ValidateAsync(context, cancellation); + } + protected void SetDefaultRules() + { #pragma warning disable S109 - RuleFor(order => order.Number).NotEmpty().MaximumLength(64); - RuleFor(order => order.CustomerId).NotNull().NotEmpty().MaximumLength(64); - RuleFor(order => order.CustomerName).NotEmpty().MaximumLength(255); - RuleFor(order => order.StoreId).NotNull().NotEmpty().MaximumLength(64); - RuleFor(order => order.StoreName).MaximumLength(255); - RuleFor(order => order.ChannelId).MaximumLength(64); - RuleFor(order => order.OrganizationId).MaximumLength(64); - RuleFor(order => order.OrganizationName).MaximumLength(255); - RuleFor(order => order.EmployeeId).MaximumLength(64); - RuleFor(order => order.EmployeeName).MaximumLength(255); - RuleFor(order => order.SubscriptionId).MaximumLength(64); - RuleFor(order => order.SubscriptionNumber).MaximumLength(64); - RuleFor(order => order.LanguageCode).MaximumLength(16) - .Matches("^[a-z]{2}-[A-Z]{2}$") - .When(order => !string.IsNullOrEmpty(order.LanguageCode)); - RuleFor(order => order.ShoppingCartId).MaximumLength(128); - RuleFor(order => order.PurchaseOrderNumber).MaximumLength(128); + RuleFor(order => order.Number).NotEmpty().MaximumLength(64); + RuleFor(order => order.CustomerId).NotNull().NotEmpty().MaximumLength(64); + RuleFor(order => order.CustomerName).NotEmpty().MaximumLength(255); + RuleFor(order => order.StoreId).NotNull().NotEmpty().MaximumLength(64); + RuleFor(order => order.StoreName).MaximumLength(255); + RuleFor(order => order.ChannelId).MaximumLength(64); + RuleFor(order => order.OrganizationId).MaximumLength(64); + RuleFor(order => order.OrganizationName).MaximumLength(255); + RuleFor(order => order.EmployeeId).MaximumLength(64); + RuleFor(order => order.EmployeeName).MaximumLength(255); + RuleFor(order => order.SubscriptionId).MaximumLength(64); + RuleFor(order => order.SubscriptionNumber).MaximumLength(64); + RuleFor(order => order.LanguageCode).MaximumLength(16) + .Matches("^[a-z]{2}-[A-Z]{2}$") + .When(order => !string.IsNullOrEmpty(order.LanguageCode)); + RuleFor(order => order.ShoppingCartId).MaximumLength(128); + RuleFor(order => order.PurchaseOrderNumber).MaximumLength(128); #pragma warning restore S109 - } } } diff --git a/src/VirtoCommerce.OrdersModule.Web/Validation/OrderDocumentCountValidator.cs b/src/VirtoCommerce.OrdersModule.Web/Validation/OrderDocumentCountValidator.cs new file mode 100644 index 000000000..b6ad7b1a4 --- /dev/null +++ b/src/VirtoCommerce.OrdersModule.Web/Validation/OrderDocumentCountValidator.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using VirtoCommerce.CoreModule.Core.Common; +using VirtoCommerce.OrdersModule.Core; +using VirtoCommerce.OrdersModule.Core.Model; +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Platform.Core.Settings; + +namespace VirtoCommerce.OrdersModule.Web.Validation; + +/// +/// Validates that the total number of child documents (operations) per order +/// does not exceed the configured maximum limit. +/// Uses the IOperation.ChildrenOperations tree structure for generic traversal. +/// This ensures system performance, storage optimization, and data consistency. +/// +/// This validator works with IOperation interface, making it applicable to any operation type, +/// though it's primarily designed for root-level operations like CustomerOrder. +/// +public class OrderDocumentCountValidator : AbstractValidator +{ + private readonly ISettingsManager _settingsManager; + + public OrderDocumentCountValidator(ISettingsManager settingsManager) + { + _settingsManager = settingsManager; + + RuleFor(operation => operation) + .CustomAsync(async (operation, context, cancellationToken) => + { + // Only validate root-level operations (like CustomerOrder) that have children + // Skip validation for child operations to avoid redundant checks + if (!string.IsNullOrEmpty(operation.ParentOperationId)) + { + return; + } + + var maxDocumentCount = await _settingsManager.GetValueAsync( + ModuleConstants.Settings.General.MaxOrderDocumentCount); + + // Get all operations in the tree (excluding the root operation itself) + var allOperations = operation.GetFlatObjectsListWithInterface().ToList(); + var childOperations = allOperations.Where(op => op.Id != operation.Id).ToList(); + + // Total child documents count + var totalDocumentCount = childOperations.Count; + + if (totalDocumentCount > maxDocumentCount) + { + var operationBreakdown = GetOperationBreakdown(childOperations); + + context.AddFailure( + operation.OperationType, + $"{operation.OperationType} document count ({totalDocumentCount}) exceeds the maximum allowed limit of {maxDocumentCount}. " + + $"Documents breakdown: {operationBreakdown}"); + } + + // Validate each operation that has children + ValidateOperationChildren(childOperations, maxDocumentCount, context); + }); + } + + /// + /// Creates a human-readable breakdown of operations by type + /// + private static string GetOperationBreakdown(IList operations) + { + var grouped = operations + .GroupBy(op => op.OperationType) + .Select(g => $"{g.Key}={g.Count()}") + .OrderBy(s => s); + + return string.Join(", ", grouped); + } + + /// + /// Validates that individual operations do not have too many child operations + /// + private static void ValidateOperationChildren( + IList allOperations, + int maxDocumentCount, + ValidationContext context) + { + // Group operations by their parent to count children per operation + var operationsByParent = allOperations + .Where(op => !string.IsNullOrEmpty(op.ParentOperationId)) + .GroupBy(op => op.ParentOperationId) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Check each operation's child count + foreach (var operation in allOperations) + { + if (operationsByParent.TryGetValue(operation.Id, out var children)) + { + var childCount = children.Count; + + if (childCount > maxDocumentCount) + { + var childBreakdown = GetOperationBreakdown(children); + + context.AddFailure( + operation.OperationType, + $"{operation.OperationType} '{operation.Number}' has {childCount} child documents ({childBreakdown}), " + + $"which exceeds the maximum allowed limit of {maxDocumentCount}."); + } + } + } + } +} + diff --git a/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceImplIntegrationTests.cs b/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceImplIntegrationTests.cs index 31ac27dc6..06caafc25 100644 --- a/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceImplIntegrationTests.cs +++ b/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceImplIntegrationTests.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; +using FluentValidation; +using FluentValidation.Results; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; @@ -12,6 +15,7 @@ using Moq; using VirtoCommerce.AssetsModule.Core.Assets; using VirtoCommerce.CoreModule.Core.Common; +using VirtoCommerce.OrdersModule.Core; using VirtoCommerce.OrdersModule.Core.Model; using VirtoCommerce.OrdersModule.Core.Model.Search; using VirtoCommerce.OrdersModule.Core.Services; @@ -28,6 +32,7 @@ using VirtoCommerce.Platform.Core.DynamicProperties; using VirtoCommerce.Platform.Core.Events; using VirtoCommerce.Platform.Core.GenericCrud; +using VirtoCommerce.Platform.Core.Settings; using VirtoCommerce.ShippingModule.Core.Model.Search; using VirtoCommerce.ShippingModule.Core.Services; using VirtoCommerce.StoreModule.Core.Services; @@ -56,6 +61,8 @@ public class CustomerOrderServiceImplIntegrationTests private readonly Mock> _logMock; private readonly Mock> _logEventMock; private readonly Mock _blobUrlResolver; + private readonly Mock _settingsManagerMock; + private readonly Mock> _customerOrderValidatorMock; public CustomerOrderServiceImplIntegrationTests() { @@ -75,6 +82,22 @@ public CustomerOrderServiceImplIntegrationTests() _logMock = new Mock>(); _logEventMock = new Mock>(); _blobUrlResolver = new Mock(); + _settingsManagerMock = new Mock(); + _customerOrderValidatorMock = new Mock>(); + + // Setup settings manager - validation disabled by default for integration tests + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.CustomerOrderValidation.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = false }); + + // Setup validator to return success by default + _customerOrderValidatorMock + .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult()); + var cachingOptions = new OptionsWrapper(new CachingOptions { CacheEnabled = true }); var memoryCache = new MemoryCache(new MemoryCacheOptions() { @@ -101,6 +124,8 @@ public CustomerOrderServiceImplIntegrationTests() container.AddSingleton(x => _changeLogServiceMock.Object); container.AddSingleton(x => _logEventMock.Object); container.AddSingleton(x => _blobUrlResolver.Object); + container.AddSingleton(x => _settingsManagerMock.Object); + container.AddSingleton(x => _customerOrderValidatorMock.Object); container.AddOptions(); var serviceProvider = container.BuildServiceProvider(); diff --git a/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceUnitTests.cs b/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceUnitTests.cs index 69209c2db..494fe9ef7 100644 --- a/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceUnitTests.cs +++ b/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderServiceUnitTests.cs @@ -2,12 +2,15 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using FluentValidation; +using FluentValidation.Results; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using VirtoCommerce.AssetsModule.Core.Assets; using VirtoCommerce.CoreModule.Core.Common; +using VirtoCommerce.OrdersModule.Core; using VirtoCommerce.OrdersModule.Core.Events; using VirtoCommerce.OrdersModule.Core.Model; using VirtoCommerce.OrdersModule.Core.Services; @@ -21,6 +24,7 @@ using VirtoCommerce.Platform.Core.Common; using VirtoCommerce.Platform.Core.Domain; using VirtoCommerce.Platform.Core.Events; +using VirtoCommerce.Platform.Core.Settings; using VirtoCommerce.ShippingModule.Core.Model.Search; using VirtoCommerce.ShippingModule.Core.Services; using VirtoCommerce.StoreModule.Core.Services; @@ -40,6 +44,7 @@ public class CustomerOrderServiceUnitTests private readonly Mock _shippingMethodsSearchServiceMock; private readonly Mock _paymentMethodsSearchServiceMock; private readonly Mock _blobUrlResolver; + private readonly Mock> _customerOrderValidatorMock; public CustomerOrderServiceUnitTests() { @@ -52,6 +57,12 @@ public CustomerOrderServiceUnitTests() _shippingMethodsSearchServiceMock = new Mock(); _paymentMethodsSearchServiceMock = new Mock(); _blobUrlResolver = new Mock(); + _customerOrderValidatorMock = new Mock>(); + + // Setup default validator to return success + _customerOrderValidatorMock + .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult()); } [Fact] @@ -181,7 +192,8 @@ private CustomerOrderService GetCustomerOrderService(IPlatformMemoryCache platfo _customerOrderTotalsCalculatorMock.Object, _shippingMethodsSearchServiceMock.Object, _paymentMethodsSearchServiceMock.Object, - _blobUrlResolver.Object); + _blobUrlResolver.Object, + _customerOrderValidatorMock.Object); } } } diff --git a/tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs b/tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs new file mode 100644 index 000000000..ee6c6b583 --- /dev/null +++ b/tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs @@ -0,0 +1,542 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentValidation.TestHelper; +using Moq; +using VirtoCommerce.OrdersModule.Core; +using VirtoCommerce.OrdersModule.Core.Model; +using VirtoCommerce.OrdersModule.Web.Validation; +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Platform.Core.Settings; +using Xunit; +using Capture = VirtoCommerce.OrdersModule.Core.Model.Capture; + +namespace VirtoCommerce.OrdersModule.Tests +{ + [Trait("Category", "Unit")] + public class OrderDocumentCountValidatorTests + { + private readonly Mock _settingsManagerMock; + private readonly OrderDocumentCountValidator _validator; + private const int DefaultMaxDocumentCount = 20; + + public OrderDocumentCountValidatorTests() + { + _settingsManagerMock = new Mock(); + + // Setup default settings value - mock the underlying GetObjectSettingAsync method + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.MaxOrderDocumentCount.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry + { + Value = DefaultMaxDocumentCount + }); + + _validator = new OrderDocumentCountValidator(_settingsManagerMock.Object); + } + + #region Happy Path Tests + + [Fact] + public async Task Validate_EmptyOrder_ShouldPass() + { + // Arrange + var order = CreateOrder(); + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public async Task Validate_OrderWithinLimit_ShouldPass() + { + // Arrange + var order = CreateOrder(); + order.InPayments = CreatePayments(5); + order.Shipments = CreateShipments(5); + + // Add captures and refunds + order.InPayments.First().Captures = CreateCaptures(3); + order.InPayments.First().Refunds = CreateRefunds(2); + + // Total: 5 payments + 5 shipments + 3 captures + 2 refunds = 15 (within limit of 20) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public async Task Validate_OrderAtExactLimit_ShouldPass() + { + // Arrange + var order = CreateOrder(); + order.InPayments = CreatePayments(10); + order.Shipments = CreateShipments(5); + order.InPayments.First().Captures = CreateCaptures(3); + order.InPayments.First().Refunds = CreateRefunds(2); + + // Total: 10 + 5 + 3 + 2 = 20 (exactly at limit) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + #endregion + + #region Failure Tests - Total Document Count Exceeded + + [Fact] + public async Task Validate_TotalDocumentCountExceeded_ShouldFail() + { + // Arrange + var order = CreateOrder(); + order.InPayments = CreatePayments(15); + order.Shipments = CreateShipments(10); + + // Total: 15 + 10 = 25 (exceeds limit of 20) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldHaveValidationErrorFor(nameof(CustomerOrder)); + Assert.Contains("25", result.Errors.First().ErrorMessage); + Assert.Contains("PaymentIn=15", result.Errors.First().ErrorMessage); + Assert.Contains("Shipment=10", result.Errors.First().ErrorMessage); + } + + [Fact] + public async Task Validate_TotalDocumentCountWithCapturesExceeded_ShouldFail() + { + // Arrange + var order = CreateOrder(); + order.InPayments = CreatePayments(10); + order.Shipments = CreateShipments(5); + order.InPayments.First().Captures = CreateCaptures(8); + + // Total: 10 + 5 + 8 = 23 (exceeds limit of 20) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldHaveValidationErrorFor(nameof(CustomerOrder)); + var errorMessage = result.Errors.First().ErrorMessage; + Assert.Contains("23", errorMessage); + Assert.Contains("Capture=8", errorMessage); + Assert.Contains("PaymentIn=10", errorMessage); + Assert.Contains("Shipment=5", errorMessage); + } + + [Fact] + public async Task Validate_TotalDocumentCountWithRefundsExceeded_ShouldFail() + { + // Arrange + var order = CreateOrder(); + order.InPayments = CreatePayments(8); + order.Shipments = CreateShipments(6); + order.InPayments.First().Refunds = CreateRefunds(7); + + // Total: 8 + 6 + 7 = 21 (exceeds limit of 20) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldHaveValidationErrorFor(nameof(CustomerOrder)); + var errorMessage = result.Errors.First().ErrorMessage; + Assert.Contains("21", errorMessage); + Assert.Contains("Refund=7", errorMessage); + Assert.Contains("PaymentIn=8", errorMessage); + Assert.Contains("Shipment=6", errorMessage); + } + + [Fact] + public async Task Validate_TotalDocumentCountWithAllTypesExceeded_ShouldFail() + { + // Arrange + var order = CreateOrder(); + order.InPayments = CreatePayments(8); + order.Shipments = CreateShipments(7); + + // Distribute captures and refunds across payments + order.InPayments.ElementAt(0).Captures = CreateCaptures(3); + order.InPayments.ElementAt(1).Refunds = CreateRefunds(4); + + // Total: 8 + 7 + 3 + 4 = 22 (exceeds limit of 20) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldHaveValidationErrorFor(nameof(CustomerOrder)); + var errorMessage = result.Errors.First().ErrorMessage; + Assert.Contains("22", errorMessage); + Assert.Contains("PaymentIn=8", errorMessage); + Assert.Contains("Shipment=7", errorMessage); + Assert.Contains("Capture=3", errorMessage); + Assert.Contains("Refund=4", errorMessage); + } + + #endregion + + #region Failure Tests - Payment Sub-documents Exceeded + + [Fact] + public async Task Validate_SinglePaymentSubDocumentsExceeded_ShouldFail() + { + // Arrange + var order = CreateOrder(); + var payment = CreatePayments(1).First(); + payment.Captures = CreateCaptures(15); + payment.Refunds = CreateRefunds(10); + order.InPayments = new List { payment }; + + // Payment sub-documents: 1 payment + 15 captures + 10 refunds = 26 total (exceeds limit of 20) + // Note: Both order-level AND payment-level validation will fail + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + // The validator reports both order-level and payment-level errors + Assert.True(result.Errors.Count >= 1, "Expected validation errors"); + + // Check for order-level error + Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("26") && e.ErrorMessage.Contains("CustomerOrder")); + + // Check for payment-level error (if reported separately) + var paymentError = result.Errors.FirstOrDefault(e => e.ErrorMessage.Contains(payment.Number)); + if (paymentError != null) + { + Assert.Contains("25", paymentError.ErrorMessage); + Assert.Contains("Capture=15", paymentError.ErrorMessage); + Assert.Contains("Refund=10", paymentError.ErrorMessage); + } + } + + [Fact] + public async Task Validate_MultiplePayments_OneExceedsSubDocumentLimit_ShouldFail() + { + // Arrange + var order = CreateOrder(); + var payments = CreatePayments(3).ToList(); + + // First payment - within limit + payments[0].Captures = CreateCaptures(5); + payments[0].Refunds = CreateRefunds(5); + + // Second payment - exceeds limit + payments[1].Captures = CreateCaptures(12); + payments[1].Refunds = CreateRefunds(10); + + // Third payment - within limit + payments[2].Captures = CreateCaptures(3); + + order.InPayments = payments; + + // Total: 3 payments + 10 + 22 + 3 = 38 (exceeds order limit) + // Payment[1]: 22 sub-docs (exceeds payment limit) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + // The validator reports both order-level and payment-level errors + Assert.True(result.Errors.Count >= 1, "Expected validation errors"); + + // Check for order-level error + Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("38") && e.ErrorMessage.Contains("CustomerOrder")); + + // Check for payment-level error (if reported separately) + var paymentError = result.Errors.FirstOrDefault(e => e.ErrorMessage.Contains(payments[1].Number)); + if (paymentError != null) + { + Assert.Contains("22", paymentError.ErrorMessage); + Assert.Contains("Capture=12", paymentError.ErrorMessage); + Assert.Contains("Refund=10", paymentError.ErrorMessage); + } + } + + #endregion + + #region Edge Cases + + [Fact] + public async Task Validate_NullCollections_ShouldPass() + { + // Arrange + var order = CreateOrder(); + order.InPayments = null; + order.Shipments = null; + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public async Task Validate_PaymentWithNullSubCollections_ShouldPass() + { + // Arrange + var order = CreateOrder(); + var payment = CreatePayments(1).First(); + payment.Captures = null; + payment.Refunds = null; + order.InPayments = new List { payment }; + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public async Task Validate_CustomMaxLimit_ShouldRespectSetting() + { + // Arrange + var customLimit = 5; + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.MaxOrderDocumentCount.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = customLimit }); + + var validator = new OrderDocumentCountValidator(_settingsManagerMock.Object); + + var order = CreateOrder(); + order.InPayments = CreatePayments(4); + order.Shipments = CreateShipments(2); + + // Total: 4 + 2 = 6 (exceeds custom limit of 5) + + // Act + var result = await validator.TestValidateAsync(order); + + // Assert + result.ShouldHaveValidationErrorFor(nameof(CustomerOrder)); + var errorMessage = result.Errors.First().ErrorMessage; + Assert.Contains("6", errorMessage); + Assert.Contains("5", errorMessage); + Assert.Contains("PaymentIn=4", errorMessage); + Assert.Contains("Shipment=2", errorMessage); + } + + [Fact] + public async Task Validate_ZeroMaxLimit_ShouldPreventAllDocuments() + { + // Arrange + var zeroLimit = 0; + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.MaxOrderDocumentCount.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = zeroLimit }); + + var validator = new OrderDocumentCountValidator(_settingsManagerMock.Object); + + var order = CreateOrder(); + order.InPayments = CreatePayments(1); + + // Act + var result = await validator.TestValidateAsync(order); + + // Assert + result.ShouldHaveValidationErrorFor(nameof(CustomerOrder)); + var errorMessage = result.Errors.First().ErrorMessage; + Assert.Contains("1", errorMessage); + Assert.Contains("0", errorMessage); + Assert.Contains("PaymentIn=1", errorMessage); + } + + [Fact] + public async Task Validate_VeryLargeMaxLimit_ShouldPass() + { + // Arrange + var largeLimit = 10000; + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.MaxOrderDocumentCount.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = largeLimit }); + + var validator = new OrderDocumentCountValidator(_settingsManagerMock.Object); + + var order = CreateOrder(); + order.InPayments = CreatePayments(100); + order.Shipments = CreateShipments(100); + + // Total: 200 (within large limit) + + // Act + var result = await validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + #endregion + + #region Real-World Scenarios + + [Fact] + public async Task Validate_TypicalECommerceOrder_ShouldPass() + { + // Arrange - Typical order: 1 payment, 2 shipments, 1 capture + var order = CreateOrder(); + order.InPayments = CreatePayments(1); + order.Shipments = CreateShipments(2); + order.InPayments.First().Captures = CreateCaptures(1); + + // Total: 1 + 2 + 1 = 4 (well within limit) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public async Task Validate_ComplexOrderWithPartialRefunds_ShouldPass() + { + // Arrange - Complex order: 2 payments, 3 shipments, 5 captures, 3 refunds + var order = CreateOrder(); + var payments = CreatePayments(2).ToList(); + payments[0].Captures = CreateCaptures(3); + payments[0].Refunds = CreateRefunds(2); + payments[1].Captures = CreateCaptures(2); + payments[1].Refunds = CreateRefunds(1); + order.InPayments = payments; + order.Shipments = CreateShipments(3); + + // Total: 2 + 3 + 5 + 3 = 13 (within limit) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public async Task Validate_SuspiciousOrderWithManyDocuments_ShouldFail() + { + // Arrange - Suspicious activity: too many payment attempts + var order = CreateOrder(); + order.InPayments = CreatePayments(25); // Many failed payment attempts + order.Shipments = CreateShipments(1); + + // Total: 25 + 1 = 26 (exceeds limit - potential fraud/abuse) + + // Act + var result = await _validator.TestValidateAsync(order); + + // Assert + result.ShouldHaveValidationErrorFor(nameof(CustomerOrder)); + } + + #endregion + + #region Helper Methods + + private CustomerOrder CreateOrder() + { + return new CustomerOrder + { + Id = Guid.NewGuid().ToString(), + Number = $"CO{DateTime.UtcNow:yyMMdd}-{new Random().Next(10000, 99999)}", + CustomerId = Guid.NewGuid().ToString(), + CustomerName = "Test Customer", + StoreId = Guid.NewGuid().ToString(), + StoreName = "Test Store", + InPayments = new List(), + Shipments = new List() + }; + } + + private ICollection CreatePayments(int count) + { + var payments = new List(); + for (int i = 0; i < count; i++) + { + payments.Add(new PaymentIn + { + Id = Guid.NewGuid().ToString(), + Number = $"PI{DateTime.UtcNow:yyMMdd}-{i:D5}", + CustomerId = Guid.NewGuid().ToString(), + CustomerName = "Test Customer", + Captures = new List(), + Refunds = new List() + }); + } + return payments; + } + + private ICollection CreateShipments(int count) + { + var shipments = new List(); + for (int i = 0; i < count; i++) + { + shipments.Add(new Shipment + { + Id = Guid.NewGuid().ToString(), + Number = $"SH{DateTime.UtcNow:yyMMdd}-{i:D5}" + }); + } + return shipments; + } + + private ICollection CreateCaptures(int count) + { + var captures = new List(); + for (int i = 0; i < count; i++) + { + captures.Add(new Capture + { + Id = Guid.NewGuid().ToString(), + Number = $"CA{DateTime.UtcNow:yyMMdd}-{i:D5}", + Amount = 100m + }); + } + return captures; + } + + private ICollection CreateRefunds(int count) + { + var refunds = new List(); + for (int i = 0; i < count; i++) + { + refunds.Add(new Refund + { + Id = Guid.NewGuid().ToString(), + Number = $"RE{DateTime.UtcNow:yyMMdd}-{i:D5}", + Amount = 50m + }); + } + return refunds; + } + + #endregion + } +} + From aa8e26b2de761bdce84167a47415d71c209f89db Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Tue, 16 Dec 2025 21:06:04 +0200 Subject: [PATCH 2/7] fix review comments --- .../Services/CustomerOrderService.cs | 32 +-- .../Validators}/CustomerOrderValidator.cs | 21 +- .../OrderDocumentCountValidator.cs | 2 +- .../Validators/PaymentInValidator.cs | 23 ++ .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Validation/PaymentInValidator.cs | 24 -- .../CustomerOrderValidatorTests.cs | 232 ++++++++++++++++++ .../OrderDocumentCountValidatorTests.cs | 3 +- 8 files changed, 286 insertions(+), 52 deletions(-) rename src/{VirtoCommerce.OrdersModule.Web/Validation => VirtoCommerce.OrdersModule.Data/Validators}/CustomerOrderValidator.cs (83%) rename src/{VirtoCommerce.OrdersModule.Web/Validation => VirtoCommerce.OrdersModule.Data/Validators}/OrderDocumentCountValidator.cs (98%) create mode 100644 src/VirtoCommerce.OrdersModule.Data/Validators/PaymentInValidator.cs delete mode 100644 src/VirtoCommerce.OrdersModule.Web/Validation/PaymentInValidator.cs create mode 100644 tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderValidatorTests.cs diff --git a/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs b/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs index 611eb446f..d3c43f3d2 100644 --- a/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs +++ b/src/VirtoCommerce.OrdersModule.Data/Services/CustomerOrderService.cs @@ -67,13 +67,6 @@ public CustomerOrderService( _customerOrderValidator = customerOrderValidator; } - protected override async Task BeforeSaveChanges(IList models) - { - await ValidateOrdersAsync(models); - - await base.BeforeSaveChanges(models); - } - public override async Task SaveChangesAsync(IList models) { var pkMap = new PrimaryKeyResolvingMap(); @@ -89,6 +82,7 @@ public override async Task SaveChangesAsync(IList models) foreach (var modifiedOrder in models) { await EnsureThatAllOperationsHaveNumber(modifiedOrder); + await ValidateOrderAsync(modifiedOrder); await LoadOrderDependenciesAsync(modifiedOrder); var originalEntity = existingEntities?.FirstOrDefault(x => x.Id == modifiedOrder.Id); @@ -324,24 +318,16 @@ private void ResolveFileUrls(CustomerOrder order) } } - /// - /// Validates customer orders if validation is enabled in settings - /// - /// Orders to validate - /// Thrown when validation fails - protected virtual async Task ValidateOrdersAsync(IList orders) + protected virtual async Task ValidateOrderAsync(CustomerOrder order) { - foreach (var order in orders) - { - var validationResult = await _customerOrderValidator.ValidateAsync(order); + var validationResult = await _customerOrderValidator.ValidateAsync(order); - if (!validationResult.IsValid) - { - var errorMessages = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); - throw new ValidationException( - $"Order '{order.Number}' validation failed: {errorMessages}", - validationResult.Errors); - } + if (!validationResult.IsValid) + { + var errorMessages = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + throw new ValidationException( + $"Order '{order.Number}' validation failed: {errorMessages}", + validationResult.Errors); } } } diff --git a/src/VirtoCommerce.OrdersModule.Web/Validation/CustomerOrderValidator.cs b/src/VirtoCommerce.OrdersModule.Data/Validators/CustomerOrderValidator.cs similarity index 83% rename from src/VirtoCommerce.OrdersModule.Web/Validation/CustomerOrderValidator.cs rename to src/VirtoCommerce.OrdersModule.Data/Validators/CustomerOrderValidator.cs index 59be9197c..5f40c627a 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Validation/CustomerOrderValidator.cs +++ b/src/VirtoCommerce.OrdersModule.Data/Validators/CustomerOrderValidator.cs @@ -9,7 +9,7 @@ using VirtoCommerce.OrdersModule.Core.Model; using VirtoCommerce.Platform.Core.Settings; -namespace VirtoCommerce.OrdersModule.Web.Validation; +namespace VirtoCommerce.OrdersModule.Data.Validators; public class CustomerOrderValidator : AbstractValidator { @@ -45,6 +45,24 @@ public CustomerOrderValidator( } } + public override ValidationResult Validate(ValidationContext context) + { + // Check if validation is enabled (synchronous version) + var isValidationEnabled = _settingsManager.GetValueAsync( + ModuleConstants.Settings.General.CustomerOrderValidation) + .GetAwaiter() + .GetResult(); + + if (!isValidationEnabled) + { + // Skip validation if disabled + return new ValidationResult(); + } + + // Perform validation if enabled + return base.Validate(context); + } + public override async Task ValidateAsync(ValidationContext context, CancellationToken cancellation = default) { // Check if validation is enabled @@ -60,6 +78,7 @@ public override async Task ValidateAsync(ValidationContext /// Validates that the total number of child documents (operations) per order diff --git a/src/VirtoCommerce.OrdersModule.Data/Validators/PaymentInValidator.cs b/src/VirtoCommerce.OrdersModule.Data/Validators/PaymentInValidator.cs new file mode 100644 index 000000000..1491621c1 --- /dev/null +++ b/src/VirtoCommerce.OrdersModule.Data/Validators/PaymentInValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using VirtoCommerce.OrdersModule.Core.Model; + +namespace VirtoCommerce.OrdersModule.Data.Validators; + +public class PaymentInValidator : AbstractValidator +{ + public PaymentInValidator() + { + SetDefaultRules(); + } + + protected void SetDefaultRules() + { + RuleFor(payment => payment.OrganizationId).MaximumLength(64); + RuleFor(payment => payment.OrganizationName).MaximumLength(255); + RuleFor(payment => payment.CustomerId).NotNull().NotEmpty().MaximumLength(64); + RuleFor(payment => payment.CustomerName).MaximumLength(255); + RuleFor(payment => payment.Purpose).MaximumLength(1024); + RuleFor(payment => payment.GatewayCode).MaximumLength(64); + RuleFor(payment => payment.TaxType).MaximumLength(64); + } +} diff --git a/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs b/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs index 665f431c6..548192f4a 100644 --- a/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/VirtoCommerce.OrdersModule.Web/Extensions/ServiceCollectionExtensions.cs @@ -3,7 +3,6 @@ using VirtoCommerce.CoreModule.Core.Common; using VirtoCommerce.OrdersModule.Core.Model; using VirtoCommerce.OrdersModule.Data.Validators; -using VirtoCommerce.OrdersModule.Web.Validation; namespace VirtoCommerce.OrdersModule.Web.Extensions { diff --git a/src/VirtoCommerce.OrdersModule.Web/Validation/PaymentInValidator.cs b/src/VirtoCommerce.OrdersModule.Web/Validation/PaymentInValidator.cs deleted file mode 100644 index e6c70db14..000000000 --- a/src/VirtoCommerce.OrdersModule.Web/Validation/PaymentInValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentValidation; -using VirtoCommerce.OrdersModule.Core.Model; - -namespace VirtoCommerce.OrdersModule.Web.Validation -{ - public class PaymentInValidator : AbstractValidator - { - public PaymentInValidator() - { - SetDefaultRules(); - } - - protected void SetDefaultRules() - { - RuleFor(payment => payment.OrganizationId).MaximumLength(64); - RuleFor(payment => payment.OrganizationName).MaximumLength(255); - RuleFor(payment => payment.CustomerId).NotNull().NotEmpty().MaximumLength(64); - RuleFor(payment => payment.CustomerName).MaximumLength(255); - RuleFor(payment => payment.Purpose).MaximumLength(1024); - RuleFor(payment => payment.GatewayCode).MaximumLength(64); - RuleFor(payment => payment.TaxType).MaximumLength(64); - } - } -} diff --git a/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderValidatorTests.cs b/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderValidatorTests.cs new file mode 100644 index 000000000..75f4c760a --- /dev/null +++ b/tests/VirtoCommerce.OrdersModule.Tests/CustomerOrderValidatorTests.cs @@ -0,0 +1,232 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentValidation; +using FluentValidation.Results; +using Moq; +using VirtoCommerce.CoreModule.Core.Common; +using VirtoCommerce.OrdersModule.Core; +using VirtoCommerce.OrdersModule.Core.Model; +using VirtoCommerce.OrdersModule.Data.Validators; +using VirtoCommerce.Platform.Core.Settings; +using Xunit; + +namespace VirtoCommerce.OrdersModule.Tests +{ + [Trait("Category", "Unit")] + public class CustomerOrderValidatorTests + { + private readonly Mock _settingsManagerMock; + private readonly Mock> _lineItemValidatorMock; + private readonly Mock> _shipmentValidatorMock; + private readonly Mock> _paymentInValidatorMock; + private readonly Mock> _operationValidatorMock; + + public CustomerOrderValidatorTests() + { + _settingsManagerMock = new Mock(); + _lineItemValidatorMock = new Mock>(); + _shipmentValidatorMock = new Mock>(); + _paymentInValidatorMock = new Mock>(); + _operationValidatorMock = new Mock>(); + + // Setup default validators to return success + _lineItemValidatorMock + .Setup(x => x.Validate(It.IsAny>())) + .Returns(new ValidationResult()); + + _shipmentValidatorMock + .Setup(x => x.Validate(It.IsAny>())) + .Returns(new ValidationResult()); + + _paymentInValidatorMock + .Setup(x => x.Validate(It.IsAny>())) + .Returns(new ValidationResult()); + + _operationValidatorMock + .Setup(x => x.Validate(It.IsAny>())) + .Returns(new ValidationResult()); + } + + [Fact] + public async Task ValidateAsync_WhenValidationDisabled_ShouldSkipValidation() + { + // Arrange + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.CustomerOrderValidation.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = false }); + + var validator = CreateValidator(); + var order = CreateInvalidOrder(); // Order that would fail validation + + // Act + var result = await validator.ValidateAsync(order); + + // Assert + Assert.True(result.IsValid, "Validation should be skipped when disabled"); + Assert.Empty(result.Errors); + } + + [Fact] + public async Task ValidateAsync_WhenValidationEnabled_ShouldPerformValidation() + { + // Arrange + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.CustomerOrderValidation.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = true }); + + var validator = CreateValidator(); + var order = CreateInvalidOrder(); // Order that would fail validation + + // Act + var result = await validator.ValidateAsync(order); + + // Assert + Assert.False(result.IsValid, "Validation should run when enabled"); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Validate_WhenValidationDisabled_ShouldSkipValidation() + { + // Arrange + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.CustomerOrderValidation.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = false }); + + var validator = CreateValidator(); + var order = CreateInvalidOrder(); // Order that would fail validation + + // Act + var result = validator.Validate(order); + + // Assert + Assert.True(result.IsValid, "Synchronous validation should be skipped when disabled"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Validate_WhenValidationEnabled_ShouldPerformValidation() + { + // Arrange + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.CustomerOrderValidation.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = true }); + + var validator = CreateValidator(); + var order = CreateInvalidOrder(); // Order that would fail validation + + // Act + var result = validator.Validate(order); + + // Assert + Assert.False(result.IsValid, "Synchronous validation should run when enabled"); + Assert.NotEmpty(result.Errors); + } + + [Fact] + public async Task ValidateAsync_ValidOrder_WhenValidationEnabled_ShouldPass() + { + // Arrange + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.CustomerOrderValidation.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = true }); + + var validator = CreateValidator(); + var order = CreateValidOrder(); + + // Act + var result = await validator.ValidateAsync(order); + + // Assert + Assert.True(result.IsValid, "Valid order should pass validation"); + Assert.Empty(result.Errors); + } + + [Fact] + public void Validate_ValidOrder_WhenValidationEnabled_ShouldPass() + { + // Arrange + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync( + ModuleConstants.Settings.General.CustomerOrderValidation.Name, + null, + null)) + .ReturnsAsync(new ObjectSettingEntry { Value = true }); + + var validator = CreateValidator(); + var order = CreateValidOrder(); + + // Act + var result = validator.Validate(order); + + // Assert + Assert.True(result.IsValid, "Valid order should pass synchronous validation"); + Assert.Empty(result.Errors); + } + + #region Helper Methods + + private CustomerOrderValidator CreateValidator() + { + return new CustomerOrderValidator( + _settingsManagerMock.Object, + new[] { _lineItemValidatorMock.Object }, + new[] { _shipmentValidatorMock.Object }, + _paymentInValidatorMock.Object, + new[] { _operationValidatorMock.Object }); + } + + private CustomerOrder CreateValidOrder() + { + return new CustomerOrder + { + Id = "test-order-id", + Number = "ORDER-001", + CustomerId = "customer-id", + CustomerName = "John Doe", + StoreId = "store-id", + StoreName = "Test Store", + Currency = "USD", + Items = new List(), + InPayments = new List(), + Shipments = new List() + }; + } + + private CustomerOrder CreateInvalidOrder() + { + return new CustomerOrder + { + Id = "test-order-id", + Number = "", // Invalid: empty number + CustomerId = "", // Invalid: empty customer ID + CustomerName = "", // Invalid: empty customer name + StoreId = "", // Invalid: empty store ID + Currency = "USD", + Items = new List(), + InPayments = new List(), + Shipments = new List() + }; + } + + #endregion + } +} + diff --git a/tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs b/tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs index ee6c6b583..e78c76b85 100644 --- a/tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs +++ b/tests/VirtoCommerce.OrdersModule.Tests/OrderDocumentCountValidatorTests.cs @@ -6,8 +6,7 @@ using Moq; using VirtoCommerce.OrdersModule.Core; using VirtoCommerce.OrdersModule.Core.Model; -using VirtoCommerce.OrdersModule.Web.Validation; -using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.OrdersModule.Data.Validators; using VirtoCommerce.Platform.Core.Settings; using Xunit; using Capture = VirtoCommerce.OrdersModule.Core.Model.Capture; From 2d928badfe75eb325ace1ea067d911c6830764ff Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Tue, 16 Dec 2025 21:14:55 +0200 Subject: [PATCH 3/7] Rewrite and expand README with full technical documentation. --- README.md | 547 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 510 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 51ce58188..cabf4088f 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,515 @@ [![CI status](https://github.com/VirtoCommerce/vc-module-order/workflows/Module%20CI/badge.svg?branch=dev)](https://github.com/VirtoCommerce/vc-module-order/actions?query=workflow%3A"Module+CI") [![Quality gate](https://sonarcloud.io/api/project_badges/measure?project=VirtoCommerce_vc-module-order&metric=alert_status&branch=dev)](https://sonarcloud.io/dashboard?id=VirtoCommerce_vc-module-order) [![Reliability rating](https://sonarcloud.io/api/project_badges/measure?project=VirtoCommerce_vc-module-order&metric=reliability_rating&branch=dev)](https://sonarcloud.io/dashboard?id=VirtoCommerce_vc-module-order) [![Security rating](https://sonarcloud.io/api/project_badges/measure?project=VirtoCommerce_vc-module-order&metric=security_rating&branch=dev)](https://sonarcloud.io/dashboard?id=VirtoCommerce_vc-module-order) [![Sqale rating](https://sonarcloud.io/api/project_badges/measure?project=VirtoCommerce_vc-module-order&metric=sqale_rating&branch=dev)](https://sonarcloud.io/dashboard?id=VirtoCommerce_vc-module-order) -The Order module in Virto Commerce is a document based flexible orders management system with possibility to add unlimited number of documents related to customer order. - -The Order module main purpose is to store order details and manage orders created by users on client side. This module is not designed to be a full order processing system like ERP but serves as storage for customer orders details and can be synchronized with different external processing systems. - -The order itself contains minimum details, when the documents present additional order details, like payment, shipment, etc. and display the order management life cycle. - -The order management process in Vitro Commerce OMS is not coded and not pre-determined. This system is designed as an Order Details Editor with no validation logics available. The system is implied to be an additional storage for customer orders details. - -## Key features - -Virto Commerce Order module supports the following functionalities: - -* Status update for each document type. -* Document based order structure. The order contains related documents such as Payments, Shipments, Addresses, etc. The order, being a document itself, is also a container for all documents related to the order processing: shipping, payment, custom documents. This approach allows mapping of supplier internal business processes of any complexity (multi-shipments, multi payments, specific inventory operations) to VirtoCommerce order structure. So it makes possible to keep track of documents begot by each order and show it to a customer if required. -* Ability to view and manage fulfillment, packages, pick-up and shipments documents. -* Dynamic extensibility of the 'Order Documents' (possibility to add custom fields). It is relatively easy to implement additional data for existent documents and new kinds of custom documents to the order container. -* Manage additional invoices. -* Save order drafts (postponed confirmation of order changes). -* Changing Order products (quantity, product change, new products). -* Possibility to make changes to order product price. -* Possibility to change discounts. -* Add promotion coupons to order. -* Payment history tracking. Orders contain document type "Payment". Using this type of documents allows keeping bills information and full logging of payment gateway transactions related to the order. -* Refunding possibilities. -* Possibility to change Product items. -* Save order details change history (logs). -* Save payment details (cards, links, phone numbers). -* Manage split shipments. -* Single shipment delivery of more than one order. -* Public API: - * Search for orders by different criteria (customer, date, etc.). The system returns brief order details; - * Manage order details; - * Prices, products, coupons, delivery addresses, promotions, order status; - * List of order related documents (order or payment cancellation, payment documents, shipment details, refund request, refunds, etc.). The document structure contains dynamically typed elements; - * Manage order delivery (status, delivery details); - * Repeated order creation (order cloning) with possibility to specify the frequency of order re-creation. +## Overview + +The Virto Commerce Order Module is a flexible, document-based order management system designed to handle complex order processing workflows. Built on a hierarchical document structure, the module enables businesses to manage orders with unlimited related documents including payments, shipments, refunds, and custom document types. + +### Architecture + +The module implements a document-centric architecture where: +- **Orders** serve as containers for related operational documents +- **Documents** (payments, shipments, captures, refunds) represent the order lifecycle stages +- **Hierarchical structure** supports nested operations through the `IOperation` interface +- **Extensibility** allows adding custom document types and fields dynamically + +### Core Principles + +- **Not a full ERP system**: Designed as an order details storage and editor, synchronized with external processing systems +- **Flexible validation**: Configurable validation rules that can be enabled or disabled based on business requirements +- **Performance optimization**: Built-in limits and constraints ensure system stability at scale +- **Document lifecycle**: Track the complete order management process through status updates and document changes + +## Key Features + +### Order Management + +* **Document-based structure**: Orders contain related documents (payments, shipments, addresses) that map to any business process complexity +* **Status management**: Independent status tracking for each document type in the order hierarchy +* **Order editing**: Modify products, quantities, prices, discounts, and add promotion coupons +* **Draft orders**: Save and manage order drafts with postponed confirmation +* **Order cloning**: Create repeated orders with configurable frequency +* **Change history**: Complete audit trail of all order modifications +* **Dynamic properties**: Extend orders with custom fields and metadata +* **Number templates**: Configurable order, payment, shipment, and refund number generation with counter reset options + +### Document Management + +* **Payment documents**: Track payment history, gateway transactions, and billing information + - Support for multiple payment methods per order + - Payment captures and authorization tracking + - Payment status lifecycle (New → Authorized → Paid → Refunded) +* **Shipment documents**: Manage fulfillment, packages, pick-up, and delivery details + - Multiple shipping methods support + - Shipment status tracking (New → PickPack → ReadyToSend → Sent) + - Split shipments and partial fulfillment +* **Refund documents**: Handle refund requests and processing with status tracking +* **Capture documents**: Track payment capture operations for authorized payments +* **Custom documents**: Extend with additional document types and dynamic fields +* **Invoice management**: Generate and manage PDF invoices + +### Validation and Constraints + +* **Configurable validation**: Enable or disable validation rules through settings (sync/async support) +* **Document count limits**: Define maximum number of child documents per order (default: 20) + - Prevents performance degradation + - Ensures storage optimization + - Maintains data consistency + - Validation at both order-level and per-document-level +* **Hierarchical validation**: Validates entire operation tree using `IOperation` interface +* **Graceful error handling**: Clear exception messages with detailed document breakdown +* **FluentValidation**: Built on FluentValidation framework for extensible validation rules +* **Custom validators**: Easy to add business-specific validation logic + +### Search and Indexing + +* **Advanced search**: Search orders by customer, date, status, store, total, and custom criteria +* **Event-based indexing**: Automatic search index updates on order changes +* **Purchased product tracking**: Index purchased products for analytics and reporting +* **Store-specific filters**: Filter purchased products by store + +### API and Integration + +* **REST API**: Full CRUD operations for orders and documents +* **GraphQL API**: Modern query interface for storefront integration +* **Search capabilities**: Advanced search with pagination and filtering +* **External system sync**: Designed for integration with ERP and other processing systems +* **Event-based architecture**: Publish domain events for order changes and lifecycle transitions + - `OrderChangeEvent` / `OrderChangedEvent` + - `OrderPaymentStatusChangedEvent` + - `OrderShipmentStatusChangedEvent` +* **Integration modules** (optional, install separately): + - **[Webhooks Module](https://github.com/VirtoCommerce/vc-module-webhooks)**: Send order events to external REST APIs with retry logic and authentication + - **[Event Bus Module](https://github.com/VirtoCommerce/vc-module-event-bus)**: Publish events to message queues (Azure Event Grid) using CloudEvents format + +### Notifications + +* **Email notifications**: Configurable email templates for order lifecycle events + - Order created + - Order status changed + - Order cancelled + - Order paid + - Order sent + - Payment status changed + - Shipment status changed +* **Invoice generation**: PDF invoice generation with customizable templates +* **Localization**: Multi-language support for notifications (10 languages) + +### Security and Permissions + +* **Role-based access control**: Granular permissions for order operations + - `order:read` - View orders + - `order:create` - Create new orders + - `order:update` - Modify existing orders + - `order:delete` - Delete orders + - `order:access` - Access order module + - `order:read_prices` - View order prices + - `order:update_shipments` - Manage shipments + - `order:capture_payment` - Capture payments + - `order:refund` - Process refunds + - `order:dashboardstatistics:view` - View dashboard statistics +* **Scope-based access**: Restrict access by store or organization + +## Configuration + +### Required Settings + +Navigate to **Settings → Orders → General** in the Admin Portal: + +#### Core Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `Order.Status` | See list | Available order statuses | +| `Order.InitialStatus` | `New` | Initial status for new orders | +| `Order.InitialProcessingStatus` | `Processing` | Status for orders with terminated payment | +| `OrderLineItem.Statuses` | See list | Available line item statuses | +| `Shipment.Status` | `New` | Available shipment statuses | +| `PaymentIn.Status` | `New` | Available payment statuses | +| `Refund.Status` | `Pending` | Available refund statuses | + +#### Number Generation + +| Setting | Default | Description | +|---------|---------|-------------| +| `Order.CustomerOrderNewNumberTemplate` | `CO{0:yyMMdd}-{1:D5}` | Order number template with counter | +| `Order.ShipmentNewNumberTemplate` | `SH{0:yyMMdd}-{1:D5}` | Shipment number template | +| `Order.PaymentInNewNumberTemplate` | `PI{0:yyMMdd}-{1:D5}` | Payment number template | +| `Order.RefundNewNumberTemplate` | `RF{0:yyMMdd}-{1:D5}` | Refund number template | + +Number template format: `