mirror of https://github.com/EasyAbp/EShop.git
54 changed files with 1162 additions and 502 deletions
@ -1,56 +1,33 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using EasyAbp.EShop.Products.Products; |
|||
using JetBrains.Annotations; |
|||
using Volo.Abp; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
[Serializable] |
|||
public class OrderDiscountContext |
|||
{ |
|||
public DateTime Now { get; } |
|||
|
|||
public IOrder Order { get; } |
|||
|
|||
public Dictionary<Guid, IProduct> ProductDict { get; } |
|||
|
|||
public List<OrderDiscountInfoModel> CandidateDiscounts { get; } |
|||
|
|||
public OrderDiscountContext(IOrder order, Dictionary<Guid, IProduct> productDict) |
|||
public OrderDiscountContext(DateTime now, IOrder order, Dictionary<Guid, IProduct> productDict, |
|||
List<OrderDiscountInfoModel> candidateDiscounts = null) |
|||
{ |
|||
Order = order; |
|||
Now = now; |
|||
Order = Check.NotNull(order, nameof(order)); |
|||
ProductDict = productDict ?? new Dictionary<Guid, IProduct>(); |
|||
CandidateDiscounts = candidateDiscounts ?? new List<OrderDiscountInfoModel>(); |
|||
} |
|||
|
|||
public List<OrderDiscountInfoModel> GetEffectDiscounts() |
|||
public OrderDiscountInfoModel FindCandidateDiscount([NotNull] string name, [CanBeNull] string key) |
|||
{ |
|||
var effectDiscounts = new List<OrderDiscountInfoModel>(); |
|||
|
|||
foreach (var discount in CandidateDiscounts.Where(x => x.EffectGroup.IsNullOrEmpty())) |
|||
{ |
|||
effectDiscounts.Add(discount); |
|||
} |
|||
|
|||
// Make sure that each OrderLine can only be affected by one discount with the same EffectGroup.
|
|||
var affectedOrderLineIdsInEffectGroup = new Dictionary<string, List<Guid>>(); |
|||
|
|||
foreach (var grouping in CandidateDiscounts.Where(x => !x.EffectGroup.IsNullOrEmpty()) |
|||
.GroupBy(x => x.EffectGroup)) |
|||
{ |
|||
var effectGroup = grouping.Key; |
|||
affectedOrderLineIdsInEffectGroup[effectGroup] = new List<Guid>(); |
|||
|
|||
// todo: can be improved to find the best discount combo.
|
|||
foreach (var discount in grouping) |
|||
{ |
|||
if (discount.AffectedOrderLineIds.Any(x => affectedOrderLineIdsInEffectGroup[effectGroup].Contains(x))) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
affectedOrderLineIdsInEffectGroup[effectGroup].AddRange(discount.AffectedOrderLineIds); |
|||
|
|||
effectDiscounts.Add(discount); |
|||
} |
|||
} |
|||
|
|||
return effectDiscounts; |
|||
return CandidateDiscounts.Find(x => x.Name == name && x.Key == key); |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public class CandidateOrderDiscounts : ICloneable |
|||
{ |
|||
public Dictionary<int, OrderDiscountInfoModel> Items { get; } |
|||
|
|||
public CandidateOrderDiscounts(List<OrderDiscountInfoModel> candidates) |
|||
{ |
|||
Items = new Dictionary<int, OrderDiscountInfoModel>(); |
|||
|
|||
foreach (var candidate in candidates) |
|||
{ |
|||
Items.Add(Items.Count, candidate); |
|||
} |
|||
} |
|||
|
|||
private CandidateOrderDiscounts(Dictionary<int, OrderDiscountInfoModel> items) |
|||
{ |
|||
Items = items ?? new Dictionary<int, OrderDiscountInfoModel>(); |
|||
} |
|||
|
|||
public object Clone() |
|||
{ |
|||
return new CandidateOrderDiscounts(Items.ToDictionary(x => x.Key, x => x.Value)); |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
using System; |
|||
using EasyAbp.EShop.Products.Products; |
|||
using NodaMoney; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public static class HasDynamicDiscountAmountExtensions |
|||
{ |
|||
/// <summary>
|
|||
/// Calculate the real-time discount amount based on the current price.
|
|||
/// The calculated discount amount has been formatted with NodaMoney and MidpointRounding.ToZero.
|
|||
/// </summary>
|
|||
public static decimal CalculateDiscountAmount(this IHasDynamicDiscountAmount value, decimal currentPrice, |
|||
string currency) |
|||
{ |
|||
var money = new Money( |
|||
value.DynamicDiscountAmount.DiscountAmount > decimal.Zero |
|||
? value.DynamicDiscountAmount.DiscountAmount |
|||
: value.DynamicDiscountAmount.DiscountRate * currentPrice, currency, MidpointRounding.ToZero); |
|||
|
|||
if (money.Amount < decimal.Zero) |
|||
{ |
|||
throw new DiscountAmountOverflowException(); |
|||
} |
|||
|
|||
if (money.Amount > currentPrice) |
|||
{ |
|||
money = new Money(currentPrice, currency); |
|||
} |
|||
|
|||
return money.Amount; |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public interface IOrderDiscountDistributor |
|||
{ |
|||
Task<OrderDiscountDistributionModel> DistributeAsync(IOrder order, Dictionary<IOrderLine, decimal> currentPrices, |
|||
OrderDiscountInfoModel discount); |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using EasyAbp.EShop.Products.Products; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public interface IOrderDiscountResolver |
|||
{ |
|||
Task<List<OrderDiscountDistributionModel>> ResolveAsync(Order order, Dictionary<Guid, IProduct> productDict); |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Volo.Abp; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public class OrderDiscountDistributionModel |
|||
{ |
|||
public OrderDiscountInfoModel DiscountInfoModel { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// OrderLine to discount amount mapping.
|
|||
/// </summary>
|
|||
public Dictionary<Guid, decimal> Distributions { get; set; } |
|||
|
|||
public OrderDiscountDistributionModel(OrderDiscountInfoModel discountInfoModel, |
|||
Dictionary<Guid, decimal> distributions) |
|||
{ |
|||
DiscountInfoModel = Check.NotNull(discountInfoModel, nameof(discountInfoModel)); |
|||
Distributions = Check.NotNull(distributions, nameof(distributions)); |
|||
|
|||
if (DiscountInfoModel.AffectedOrderLineIds.Any(x => !Distributions.ContainsKey(x))) |
|||
{ |
|||
throw new AbpException("The OrderDiscountDistributionModel got incorrect distributions."); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using EasyAbp.EShop.Products.Products; |
|||
using NodaMoney; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public class OrderDiscountDistributor : IOrderDiscountDistributor, ITransientDependency |
|||
{ |
|||
public virtual Task<OrderDiscountDistributionModel> DistributeAsync(IOrder order, |
|||
Dictionary<IOrderLine, decimal> currentPrices, OrderDiscountInfoModel discount) |
|||
{ |
|||
var affectedOrderLines = discount.AffectedOrderLineIds |
|||
.Select(orderLineId => order.OrderLines.Single(x => x.Id == orderLineId)) |
|||
.ToList(); |
|||
|
|||
var affectedOrderLinesCurrentPrice = |
|||
new Money(affectedOrderLines.Sum(x => currentPrices[x]), order.Currency); |
|||
|
|||
var totalDiscountAmount = discount.CalculateDiscountAmount(affectedOrderLinesCurrentPrice.Amount, order.Currency); |
|||
|
|||
var distributions = new Dictionary<Guid, decimal>(); |
|||
var remainingDiscountAmount = totalDiscountAmount; |
|||
|
|||
foreach (var orderLine in affectedOrderLines) |
|||
{ |
|||
var calculatedDiscountAmount = new Money( |
|||
currentPrices[orderLine] / affectedOrderLinesCurrentPrice.Amount * |
|||
totalDiscountAmount, order.Currency, MidpointRounding.ToZero); |
|||
|
|||
var discountAmount = calculatedDiscountAmount.Amount > currentPrices[orderLine] |
|||
? currentPrices[orderLine] |
|||
: calculatedDiscountAmount.Amount; |
|||
|
|||
distributions[orderLine.Id] = discountAmount; |
|||
currentPrices[orderLine] -= discountAmount; |
|||
remainingDiscountAmount -= discountAmount; |
|||
} |
|||
|
|||
foreach (var orderLine in affectedOrderLines.OrderByDescending(x => currentPrices[x])) |
|||
{ |
|||
if (remainingDiscountAmount == decimal.Zero) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
var discountAmount = remainingDiscountAmount > currentPrices[orderLine] |
|||
? currentPrices[orderLine] |
|||
: remainingDiscountAmount; |
|||
|
|||
distributions[orderLine.Id] += discountAmount; |
|||
currentPrices[orderLine] -= discountAmount; |
|||
remainingDiscountAmount -= discountAmount; |
|||
} |
|||
|
|||
if (remainingDiscountAmount != decimal.Zero) |
|||
{ |
|||
throw new ApplicationException("The OrderDiscountDistributor failed to distribute the remaining"); |
|||
} |
|||
|
|||
return Task.FromResult(new OrderDiscountDistributionModel(discount, distributions)); |
|||
} |
|||
} |
|||
@ -0,0 +1,85 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using EasyAbp.EShop.Products.Products; |
|||
using Volo.Abp; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public class OrderDiscountElectionModel |
|||
{ |
|||
/// <summary>
|
|||
/// The search will stop and throw an exception if the number of executions is greater than <see cref="MaxDepth"/>.
|
|||
/// <see cref="MaxDepth"/> = Math.Pow(2, <see cref="MaxCandidates"/>)
|
|||
/// </summary>
|
|||
public static int MaxCandidates { get; set; } = 10; |
|||
|
|||
/// <summary>
|
|||
/// The search will stop and throw an exception if the number of executions is greater than <see cref="MaxDepth"/>.
|
|||
/// This is to prevent a dead loop.
|
|||
/// </summary>
|
|||
private static double MaxDepth { get; set; } = Math.Pow(2, MaxCandidates); |
|||
|
|||
/// <summary>
|
|||
/// The search will stop and throw an exception if this value is greater than <see cref="MaxDepth"/>.
|
|||
/// </summary>
|
|||
public double CurrentDepth { get; private set; } |
|||
|
|||
public IOrder Order { get; } |
|||
|
|||
public Dictionary<Guid, IProduct> ProductDict { get; } |
|||
|
|||
public List<OrderDiscountsSchemeModel> Schemes { get; } = new(); |
|||
|
|||
public bool Done => Schemes.Any() && !CandidateDiscountsQueue.Any(); |
|||
|
|||
private Queue<CandidateOrderDiscounts> CandidateDiscountsQueue { get; } = new(); |
|||
|
|||
private HashSet<string> UsedCombinations { get; } = new(); |
|||
|
|||
public OrderDiscountElectionModel(IOrder order, Dictionary<Guid, IProduct> productDict) |
|||
{ |
|||
Order = order; |
|||
ProductDict = productDict; |
|||
} |
|||
|
|||
public OrderDiscountsSchemeModel GetBestScheme() |
|||
{ |
|||
if (!Done) |
|||
{ |
|||
throw new AbpException("The OrderDiscountElectionModel is in an incorrect state."); |
|||
} |
|||
|
|||
return Schemes.MaxBy(x => x.TotalDiscountAmount); |
|||
} |
|||
|
|||
public bool TryEnqueue(CandidateOrderDiscounts candidateDiscounts) |
|||
{ |
|||
if (candidateDiscounts.Items.Count > MaxCandidates) |
|||
{ |
|||
throw new AbpException( |
|||
$"The OrderDiscountElectionModel cannot handle a CandidateOrderDiscounts with the number of candidate discounts > {MaxCandidates}."); |
|||
} |
|||
|
|||
CurrentDepth++; |
|||
|
|||
if (CurrentDepth > MaxDepth || CurrentDepth < 0) |
|||
{ |
|||
throw new AbpException("The OrderDiscountElectionModel's search exceeded maximum depth."); |
|||
} |
|||
|
|||
if (!UsedCombinations.Add(candidateDiscounts.Items.Select(pair => pair.Key).JoinAsString(";"))) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
CandidateDiscountsQueue.Enqueue(candidateDiscounts); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
public CandidateOrderDiscounts Dequeue() |
|||
{ |
|||
return CandidateDiscountsQueue.Dequeue(); |
|||
} |
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using EasyAbp.EShop.Products.Products; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public class OrderDiscountResolver : IOrderDiscountResolver, ITransientDependency |
|||
{ |
|||
public IAbpLazyServiceProvider LazyServiceProvider { get; set; } |
|||
|
|||
protected IOrderDiscountDistributor OrderDiscountDistributor => |
|||
LazyServiceProvider.LazyGetRequiredService<IOrderDiscountDistributor>(); |
|||
|
|||
public virtual async Task<List<OrderDiscountDistributionModel>> ResolveAsync(Order order, |
|||
Dictionary<Guid, IProduct> productDict) |
|||
{ |
|||
var context = new OrderDiscountContext(order.CreationTime, order, productDict); |
|||
|
|||
foreach (var provider in LazyServiceProvider.LazyGetService<IEnumerable<IOrderDiscountProvider>>() |
|||
.OrderBy(x => x.EffectOrder)) |
|||
{ |
|||
await provider.DiscountAsync(context); |
|||
} |
|||
|
|||
if (context.CandidateDiscounts.IsNullOrEmpty()) |
|||
{ |
|||
return new List<OrderDiscountDistributionModel>(); |
|||
} |
|||
|
|||
var electionModel = new OrderDiscountElectionModel(context.Order, context.ProductDict); |
|||
electionModel.TryEnqueue(new CandidateOrderDiscounts(context.CandidateDiscounts)); |
|||
|
|||
while (!electionModel.Done) |
|||
{ |
|||
await EvolveAsync(electionModel); |
|||
} |
|||
|
|||
return electionModel.GetBestScheme().Distributions; |
|||
} |
|||
|
|||
protected virtual async Task EvolveAsync(OrderDiscountElectionModel electionModel) |
|||
{ |
|||
if (electionModel.Done) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var order = electionModel.Order; |
|||
var candidateDiscounts = electionModel.Dequeue(); |
|||
|
|||
// Make sure that each OrderLine can only be affected by one discount with the same EffectGroup.
|
|||
var affectedOrderLineIdsInEffectGroup = new Dictionary<string, List<Guid>>(); |
|||
var usedDiscountNameKeyPairs = new HashSet<(string, string)>(); |
|||
|
|||
var currentPrices = |
|||
new Dictionary<IOrderLine, decimal>(order.OrderLines.ToDictionary(x => x, x => x.UnitPrice)); |
|||
|
|||
var distributionModels = new List<OrderDiscountDistributionModel>(); |
|||
|
|||
foreach (var (index, discount) in candidateDiscounts.Items) |
|||
{ |
|||
if (!usedDiscountNameKeyPairs.Add(new ValueTuple<string, string>(discount.Name, discount.Key))) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
if (!discount.EffectGroup.IsNullOrEmpty()) |
|||
{ |
|||
var affectedOrderLineIds = |
|||
affectedOrderLineIdsInEffectGroup.GetOrAdd(discount.EffectGroup!, () => new List<Guid>()); |
|||
|
|||
if (discount.AffectedOrderLineIds.Any(x => affectedOrderLineIds.Contains(x))) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
affectedOrderLineIdsInEffectGroup[discount.EffectGroup!].AddRange(discount.AffectedOrderLineIds); |
|||
} |
|||
|
|||
if (candidateDiscounts.Items.Values.Any(x => x.EffectGroup == discount.EffectGroup && x != discount)) |
|||
{ |
|||
// remove the current discount and try again to find a better scheme
|
|||
var newCandidateDiscounts = (CandidateOrderDiscounts)candidateDiscounts.Clone(); |
|||
newCandidateDiscounts.Items.Remove(index); |
|||
electionModel.TryEnqueue(newCandidateDiscounts); |
|||
} |
|||
|
|||
var distributionResult = await OrderDiscountDistributor.DistributeAsync(order, currentPrices, discount); |
|||
|
|||
distributionModels.Add(new OrderDiscountDistributionModel(discount, distributionResult.Distributions)); |
|||
} |
|||
|
|||
electionModel.Schemes.Add(new OrderDiscountsSchemeModel(distributionModels)); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public class OrderDiscountsSchemeModel |
|||
{ |
|||
public List<OrderDiscountDistributionModel> Distributions { get; } |
|||
|
|||
public decimal TotalDiscountAmount => Distributions.SelectMany(x => x.Distributions).Sum(x => x.Value); |
|||
|
|||
public OrderDiscountsSchemeModel(List<OrderDiscountDistributionModel> distributions) |
|||
{ |
|||
Distributions = distributions; |
|||
} |
|||
} |
|||
@ -0,0 +1,161 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using EasyAbp.EShop.Products.Products; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace EasyAbp.EShop.Orders.Orders; |
|||
|
|||
public class OrderDiscountDistributorTests : OrdersDomainTestBase |
|||
{ |
|||
private IOrderDiscountDistributor OrderDiscountDistributor { get; } |
|||
|
|||
public OrderDiscountDistributorTests() |
|||
{ |
|||
OrderDiscountDistributor = GetRequiredService<IOrderDiscountDistributor>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Fall_Back_Discount_Amount_Using_MidpointRounding_ToZero() |
|||
{ |
|||
var orderGenerator = GetRequiredService<INewOrderGenerator>(); |
|||
|
|||
var createOrderInfoModel = new CreateOrderInfoModel(OrderTestData.Store1Id, null, |
|||
new List<CreateOrderLineInfoModel> |
|||
{ |
|||
new(OrderTestData.Product1Id, OrderTestData.ProductSku1Id, 1), |
|||
} |
|||
); |
|||
|
|||
var order = await orderGenerator.GenerateAsync(Guid.NewGuid(), createOrderInfoModel, |
|||
new Dictionary<Guid, IProduct> |
|||
{ |
|||
{ |
|||
OrderTestData.Product1Id, new ProductEto |
|||
{ |
|||
Id = OrderTestData.Product1Id, |
|||
ProductSkus = new List<ProductSkuEto> |
|||
{ |
|||
new() |
|||
{ |
|||
Id = OrderTestData.ProductSku1Id, |
|||
AttributeOptionIds = new List<Guid>(), |
|||
Price = 0.99m, |
|||
Currency = "USD", |
|||
OrderMinQuantity = 1, |
|||
OrderMaxQuantity = 100, |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, new Dictionary<Guid, DateTime>()); |
|||
|
|||
const decimal discountRate = 0.50m; |
|||
|
|||
var discount = new OrderDiscountInfoModel(order.OrderLines.Select(x => x.Id).ToList(), null, "Test", null, |
|||
null, new DynamicDiscountAmountModel("USD", 0m, discountRate, null)); |
|||
|
|||
var currentPrices = |
|||
new Dictionary<IOrderLine, decimal>(order.OrderLines.ToDictionary(x => (IOrderLine)x, |
|||
x => x.UnitPrice)); |
|||
|
|||
var distributionResult = await OrderDiscountDistributor.DistributeAsync(order, currentPrices, discount); |
|||
|
|||
order.AddDiscounts(distributionResult); |
|||
|
|||
var orderLine1 = order.OrderLines[0]; |
|||
var discountedAmount1 = Math.Round(orderLine1.TotalPrice * discountRate, 2, MidpointRounding.ToZero); |
|||
|
|||
discountedAmount1.ShouldBe(0.49m); |
|||
|
|||
var discount1 = order.OrderDiscounts.Find(x => x.OrderLineId == orderLine1.Id); |
|||
|
|||
order.OrderDiscounts.Count.ShouldBe(1); |
|||
discount1.ShouldNotBeNull(); |
|||
|
|||
discount1.DiscountedAmount.ShouldBe(discountedAmount1); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Discount_Multi_OrderLine() |
|||
{ |
|||
var orderGenerator = GetRequiredService<INewOrderGenerator>(); |
|||
|
|||
var createOrderInfoModel = new CreateOrderInfoModel(OrderTestData.Store1Id, null, |
|||
new List<CreateOrderLineInfoModel> |
|||
{ |
|||
new(OrderTestData.Product1Id, OrderTestData.ProductSku1Id, 1), |
|||
new(OrderTestData.Product1Id, OrderTestData.ProductSku2Id, 1), |
|||
} |
|||
); |
|||
|
|||
var order = await orderGenerator.GenerateAsync(Guid.NewGuid(), createOrderInfoModel, |
|||
new Dictionary<Guid, IProduct> |
|||
{ |
|||
{ |
|||
OrderTestData.Product1Id, new ProductEto |
|||
{ |
|||
Id = OrderTestData.Product1Id, |
|||
ProductSkus = new List<ProductSkuEto> |
|||
{ |
|||
new() |
|||
{ |
|||
Id = OrderTestData.ProductSku1Id, |
|||
AttributeOptionIds = new List<Guid>(), |
|||
Price = 1.7m, |
|||
Currency = "USD", |
|||
OrderMinQuantity = 1, |
|||
OrderMaxQuantity = 100, |
|||
}, |
|||
new() |
|||
{ |
|||
Id = OrderTestData.ProductSku2Id, |
|||
AttributeOptionIds = new List<Guid>(), |
|||
Price = 3.11m, |
|||
Currency = "USD", |
|||
OrderMinQuantity = 1, |
|||
OrderMaxQuantity = 100, |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, new Dictionary<Guid, DateTime>()); |
|||
|
|||
const decimal discountedAmount = 0.9m; |
|||
|
|||
var discount = new OrderDiscountInfoModel(order.OrderLines.Select(x => x.Id).ToList(), null, "Test", null, |
|||
null, new DynamicDiscountAmountModel("USD", discountedAmount, 0m, null)); |
|||
|
|||
var currentPrices = |
|||
new Dictionary<IOrderLine, decimal>(order.OrderLines.ToDictionary(x => (IOrderLine)x, |
|||
x => x.UnitPrice)); |
|||
|
|||
var distributionResult = await OrderDiscountDistributor.DistributeAsync(order, currentPrices, discount); |
|||
|
|||
order.AddDiscounts(distributionResult); |
|||
|
|||
var orderLine1 = order.OrderLines[0]; |
|||
var orderLine2 = order.OrderLines[1]; |
|||
var discountedAmount1 = Math.Round(discountedAmount * (orderLine1.TotalPrice / order.TotalPrice), 2, |
|||
MidpointRounding.ToZero); |
|||
var discountedAmount2 = Math.Round(discountedAmount * (orderLine2.TotalPrice / order.TotalPrice), 2, |
|||
MidpointRounding.ToZero); |
|||
var fraction = discountedAmount - discountedAmount1 - discountedAmount2; |
|||
|
|||
discountedAmount1.ShouldBe(0.31m); |
|||
discountedAmount2.ShouldBe(0.58m); |
|||
fraction.ShouldBe(0.01m); |
|||
|
|||
var discount1 = order.OrderDiscounts.Find(x => x.OrderLineId == orderLine1.Id); |
|||
var discount2 = order.OrderDiscounts.Find(x => x.OrderLineId == orderLine2.Id); |
|||
|
|||
order.OrderDiscounts.Count.ShouldBe(2); |
|||
discount1.ShouldNotBeNull(); |
|||
discount2.ShouldNotBeNull(); |
|||
|
|||
discount1.DiscountedAmount.ShouldBe(discountedAmount1); |
|||
discount2.DiscountedAmount.ShouldBe(discountedAmount2 + fraction); |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
using System; |
|||
using JetBrains.Annotations; |
|||
using Volo.Abp; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
[Serializable] |
|||
public class CandidateProductDiscountInfoModel : DiscountInfoModel, IHasDynamicDiscountAmount |
|||
{ |
|||
public DynamicDiscountAmountModel DynamicDiscountAmount { get; set; } |
|||
|
|||
public CandidateProductDiscountInfoModel() |
|||
{ |
|||
} |
|||
|
|||
public CandidateProductDiscountInfoModel([CanBeNull] string effectGroup, [NotNull] string name, |
|||
[CanBeNull] string key, [CanBeNull] string displayName, DynamicDiscountAmountModel dynamicDiscountAmount, |
|||
DateTime? fromTime, DateTime? toTime) : base(effectGroup, name, key, displayName, fromTime, toTime) |
|||
{ |
|||
DynamicDiscountAmount = Check.NotNull(dynamicDiscountAmount, nameof(dynamicDiscountAmount)); |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
using System; |
|||
using JetBrains.Annotations; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
[Serializable] |
|||
public class DynamicDiscountAmountModel |
|||
{ |
|||
public string Currency { get; } |
|||
|
|||
/// <summary>
|
|||
/// The absolute discount amount.
|
|||
/// <see cref="DiscountAmount"/> has a higher priority of effectiveness than <see cref="DiscountRate"/>.
|
|||
/// And <see cref="DiscountRate"/> has a higher priority of effectiveness than <see cref="CalculatorName"/>.
|
|||
/// </summary>
|
|||
public decimal DiscountAmount { get; } |
|||
|
|||
/// <summary>
|
|||
/// The discount rate. This means 10% off if you set it to <value>0.1</value>.
|
|||
/// <see cref="DiscountAmount"/> has a higher priority of effectiveness than <see cref="DiscountRate"/>.
|
|||
/// And <see cref="DiscountRate"/> has a higher priority of effectiveness than <see cref="CalculatorName"/>.
|
|||
/// </summary>
|
|||
public decimal DiscountRate { get; } |
|||
|
|||
/// <summary>
|
|||
/// (todo: NOT YET IMPLEMENTED!)
|
|||
/// The name of the runtime discount calculator.
|
|||
/// <see cref="DiscountAmount"/> has a higher priority of effectiveness than <see cref="DiscountRate"/>.
|
|||
/// And <see cref="DiscountRate"/> has a higher priority of effectiveness than <see cref="CalculatorName"/>.
|
|||
/// </summary>
|
|||
[CanBeNull] |
|||
public string CalculatorName { get; } |
|||
|
|||
public DynamicDiscountAmountModel(string currency, decimal discountAmount, decimal discountRate, |
|||
[CanBeNull] string calculatorName) |
|||
{ |
|||
if (discountAmount < decimal.Zero || discountRate < decimal.Zero) |
|||
{ |
|||
throw new DiscountAmountOverflowException(); |
|||
} |
|||
|
|||
Currency = currency; |
|||
DiscountAmount = discountAmount; |
|||
DiscountRate = discountRate; |
|||
CalculatorName = calculatorName; |
|||
} |
|||
} |
|||
@ -1,146 +0,0 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using JetBrains.Annotations; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public static class HasDiscountsForProductExtensions |
|||
{ |
|||
public static void FillEffectState(this IHasDiscountsForProduct hasDiscountsForProduct, DateTime now) |
|||
{ |
|||
var effectGroupsBestModel = new Dictionary<string, ProductDiscountInfoModel>(); |
|||
|
|||
foreach (var model in hasDiscountsForProduct.ProductDiscounts.Where(model => |
|||
(!model.FromTime.HasValue || model.FromTime <= now) && |
|||
(!model.ToTime.HasValue || model.ToTime >= now))) |
|||
{ |
|||
if (model.EffectGroup.IsNullOrEmpty()) |
|||
{ |
|||
model.InEffect = true; |
|||
} |
|||
else |
|||
{ |
|||
effectGroupsBestModel.TryGetValue(model.EffectGroup!, out var existing); |
|||
|
|||
if (existing is null) |
|||
{ |
|||
model.InEffect = true; |
|||
effectGroupsBestModel[model.EffectGroup] = model; |
|||
} |
|||
else if (effectGroupsBestModel[model.EffectGroup].DiscountedAmount < model.DiscountedAmount) |
|||
{ |
|||
effectGroupsBestModel[model.EffectGroup].InEffect = false; |
|||
model.InEffect = true; |
|||
effectGroupsBestModel[model.EffectGroup] = model; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// It returns a sum of the amount of the product discounts currently in effect.
|
|||
/// </summary>
|
|||
public static decimal GetDiscountedAmount(this IHasDiscountsForProduct hasDiscountsForProduct) |
|||
{ |
|||
return hasDiscountsForProduct.ProductDiscounts.Where(x => x.InEffect == true).Sum(x => x.DiscountedAmount); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// It returns the price minus the product discounts currently in effect.
|
|||
/// </summary>
|
|||
public static decimal GetDiscountedPrice(this IHasFullDiscountsForProduct hasFullDiscountsForProduct) |
|||
{ |
|||
return hasFullDiscountsForProduct.PriceWithoutDiscount - hasFullDiscountsForProduct.GetDiscountedAmount(); |
|||
} |
|||
|
|||
public static ProductDiscountInfoModel FindProductDiscount(this IHasDiscountsForProduct hasDiscountsForProduct, |
|||
[NotNull] string name, [CanBeNull] string key) |
|||
{ |
|||
return hasDiscountsForProduct.ProductDiscounts.Find(x => x.Name == name && x.Key == key); |
|||
} |
|||
|
|||
public static void AddOrUpdateProductDiscount(this IHasDiscountsForProduct hasDiscountsForProduct, |
|||
ProductDiscountInfoModel model) |
|||
{ |
|||
var found = hasDiscountsForProduct.FindProductDiscount(model.Name, model.Key); |
|||
|
|||
if (found is null) |
|||
{ |
|||
hasDiscountsForProduct.ProductDiscounts.Add(model); |
|||
} |
|||
else |
|||
{ |
|||
hasDiscountsForProduct.ProductDiscounts.ReplaceOne(found, model); |
|||
} |
|||
|
|||
hasDiscountsForProduct.CheckDiscountedAmount(); |
|||
} |
|||
|
|||
public static bool TryRemoveProductDiscount(this IHasDiscountsForProduct hasDiscountsForProduct, |
|||
[NotNull] string name, |
|||
[CanBeNull] string key) |
|||
{ |
|||
var found = hasDiscountsForProduct.FindProductDiscount(name, key); |
|||
|
|||
if (found is null) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
hasDiscountsForProduct.ProductDiscounts.Remove(found); |
|||
|
|||
hasDiscountsForProduct.CheckDiscountedAmount(); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
public static OrderDiscountPreviewInfoModel FindOrderDiscountPreview( |
|||
this IHasDiscountsForProduct hasDiscountsForProduct, [NotNull] string name, [CanBeNull] string key) |
|||
{ |
|||
return hasDiscountsForProduct.OrderDiscountPreviews.Find(x => x.Name == name && x.Key == key); |
|||
} |
|||
|
|||
public static void AddOrUpdateOrderDiscountPreview(this IHasDiscountsForProduct hasDiscountsForProduct, |
|||
OrderDiscountPreviewInfoModel model) |
|||
{ |
|||
var found = hasDiscountsForProduct.FindOrderDiscountPreview(model.Name, model.Key); |
|||
|
|||
if (found is null) |
|||
{ |
|||
hasDiscountsForProduct.OrderDiscountPreviews.Add(model); |
|||
} |
|||
else |
|||
{ |
|||
hasDiscountsForProduct.OrderDiscountPreviews.ReplaceOne(found, model); |
|||
} |
|||
|
|||
hasDiscountsForProduct.CheckDiscountedAmount(); |
|||
} |
|||
|
|||
public static bool TryRemoveOrderDiscountPreview(this IHasDiscountsForProduct hasDiscountsForProduct, |
|||
[NotNull] string name, |
|||
[CanBeNull] string key) |
|||
{ |
|||
var found = hasDiscountsForProduct.FindOrderDiscountPreview(name, key); |
|||
|
|||
if (found is null) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
hasDiscountsForProduct.OrderDiscountPreviews.Remove(found); |
|||
|
|||
hasDiscountsForProduct.CheckDiscountedAmount(); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
private static void CheckDiscountedAmount(this IHasDiscountsForProduct hasDiscountsForProduct) |
|||
{ |
|||
if (hasDiscountsForProduct.ProductDiscounts.Any(x => x.DiscountedAmount < decimal.Zero)) |
|||
{ |
|||
throw new DiscountAmountOverflowException(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public interface IHasDynamicDiscountAmount |
|||
{ |
|||
DynamicDiscountAmountModel DynamicDiscountAmount { get; } |
|||
} |
|||
@ -1,91 +1,34 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using JetBrains.Annotations; |
|||
using Volo.Abp; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
[Serializable] |
|||
public class ProductDiscountContext |
|||
{ |
|||
public DateTime Now { get; } |
|||
|
|||
public decimal PriceFromPriceProvider { get; } |
|||
|
|||
public IProduct Product { get; } |
|||
|
|||
public IProductSku ProductSku { get; } |
|||
|
|||
public decimal PriceWithoutDiscount => PriceModel.PriceWithoutDiscount; |
|||
|
|||
public IReadOnlyList<ProductDiscountInfoModel> ProductDiscounts => PriceModel.ProductDiscounts; |
|||
public List<CandidateProductDiscountInfoModel> CandidateProductDiscounts { get; } |
|||
|
|||
public IReadOnlyList<OrderDiscountPreviewInfoModel> OrderDiscountPreviews => PriceModel.OrderDiscountPreviews; |
|||
public List<OrderDiscountPreviewInfoModel> OrderDiscountPreviews { get; } |
|||
|
|||
private ProductPriceModel PriceModel { get; } |
|||
|
|||
public ProductDiscountContext(IProduct product, IProductSku productSku, decimal priceFromPriceProvider, |
|||
DateTime now) |
|||
public ProductDiscountContext(DateTime now, IProduct product, IProductSku productSku, |
|||
decimal priceFromPriceProvider, List<CandidateProductDiscountInfoModel> candidateProductDiscounts = null, |
|||
List<OrderDiscountPreviewInfoModel> orderDiscountPreviews = null) |
|||
{ |
|||
Product = product; |
|||
ProductSku = productSku; |
|||
PriceModel = new ProductPriceModel(priceFromPriceProvider); |
|||
Now = now; |
|||
} |
|||
|
|||
public ProductDiscountInfoModel FindProductDiscount([NotNull] string name, [CanBeNull] string key) |
|||
{ |
|||
return PriceModel.FindProductDiscount(name, key); |
|||
} |
|||
|
|||
public void AddOrUpdateProductDiscount(ProductDiscountInfoModel model) |
|||
{ |
|||
PriceModel.AddOrUpdateProductDiscount(model); |
|||
PriceModel.FillEffectState(Now); |
|||
} |
|||
|
|||
public bool TryRemoveProductDiscount([NotNull] string name, [CanBeNull] string key) |
|||
{ |
|||
var result = PriceModel.TryRemoveProductDiscount(name, key); |
|||
Product = Check.NotNull(product, nameof(product)); |
|||
ProductSku = Check.NotNull(productSku, nameof(productSku)); |
|||
PriceFromPriceProvider = priceFromPriceProvider; |
|||
|
|||
PriceModel.FillEffectState(Now); |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public OrderDiscountPreviewInfoModel FindOrderDiscountPreview([NotNull] string name, [CanBeNull] string key) |
|||
{ |
|||
return PriceModel.FindOrderDiscountPreview(name, key); |
|||
} |
|||
|
|||
public void AddOrUpdateOrderDiscountPreview(OrderDiscountPreviewInfoModel model) |
|||
{ |
|||
PriceModel.AddOrUpdateOrderDiscountPreview(model); |
|||
} |
|||
|
|||
public bool TryRemoveOrderDiscountPreview([NotNull] string name, [CanBeNull] string key) |
|||
{ |
|||
return PriceModel.TryRemoveOrderDiscountPreview(name, key); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// It returns a sum of the amount of the product discounts currently in effect.
|
|||
/// </summary>
|
|||
public decimal GetDiscountedAmount(string excludingEffectGroup = null) |
|||
{ |
|||
return PriceModel.ProductDiscounts |
|||
.Where(x => x.InEffect == true) |
|||
.WhereIf(excludingEffectGroup != null, x => x.EffectGroup != excludingEffectGroup) |
|||
.Sum(x => x.DiscountedAmount); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// It returns the price minus the product discounts currently in effect.
|
|||
/// </summary>
|
|||
public decimal GetDiscountedPrice(string excludingEffectGroup = null) |
|||
{ |
|||
return PriceWithoutDiscount - GetDiscountedAmount(excludingEffectGroup); |
|||
} |
|||
|
|||
public ProductPriceModel ToFinalProductPriceModel() |
|||
{ |
|||
return PriceModel; |
|||
CandidateProductDiscounts = candidateProductDiscounts ?? new List<CandidateProductDiscountInfoModel>(); |
|||
OrderDiscountPreviews = orderDiscountPreviews ?? new List<OrderDiscountPreviewInfoModel>(); |
|||
} |
|||
} |
|||
@ -1,23 +0,0 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public class ProductPriceModel : IHasFullDiscountsForProduct |
|||
{ |
|||
public decimal PriceWithoutDiscount { get; } |
|||
|
|||
public List<ProductDiscountInfoModel> ProductDiscounts { get; } = new(); |
|||
|
|||
public List<OrderDiscountPreviewInfoModel> OrderDiscountPreviews { get; } = new(); |
|||
|
|||
public ProductPriceModel(decimal priceWithoutDiscount) |
|||
{ |
|||
if (PriceWithoutDiscount < decimal.Zero) |
|||
{ |
|||
throw new OverflowException(); |
|||
} |
|||
|
|||
PriceWithoutDiscount = priceWithoutDiscount; |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
using System.Linq; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public class RealTimePriceInfoModel |
|||
{ |
|||
public decimal PriceWithoutDiscount { get; } |
|||
|
|||
public DiscountForProductModels Discounts { get; } |
|||
|
|||
public decimal TotalDiscountAmount => |
|||
Discounts.ProductDiscounts.Where(x => x.InEffect).Sum(x => x.DiscountedAmount); |
|||
|
|||
public decimal TotalDiscountedPrice => PriceWithoutDiscount - TotalDiscountAmount; |
|||
|
|||
public RealTimePriceInfoModel(decimal priceWithoutDiscount, DiscountForProductModels discounts) |
|||
{ |
|||
PriceWithoutDiscount = priceWithoutDiscount; |
|||
Discounts = discounts; |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public class CandidateProductDiscounts : ICloneable |
|||
{ |
|||
public Dictionary<int, CandidateProductDiscountInfoModel> Items { get; } |
|||
|
|||
public CandidateProductDiscounts(List<CandidateProductDiscountInfoModel> candidates) |
|||
{ |
|||
Items = new Dictionary<int, CandidateProductDiscountInfoModel>(); |
|||
|
|||
foreach (var candidate in candidates) |
|||
{ |
|||
Items.Add(Items.Count, candidate); |
|||
} |
|||
} |
|||
|
|||
private CandidateProductDiscounts(Dictionary<int, CandidateProductDiscountInfoModel> items) |
|||
{ |
|||
Items = items ?? new Dictionary<int, CandidateProductDiscountInfoModel>(); |
|||
} |
|||
|
|||
public object Clone() |
|||
{ |
|||
return new CandidateProductDiscounts(Items.ToDictionary(x => x.Key, x => x.Value)); |
|||
} |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
using System; |
|||
using NodaMoney; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public static class HasDynamicDiscountAmountExtensions |
|||
{ |
|||
/// <summary>
|
|||
/// Calculate the real-time discount amount based on the current price.
|
|||
/// The calculated discount amount has been formatted with NodaMoney and MidpointRounding.ToZero.
|
|||
/// </summary>
|
|||
public static decimal CalculateDiscountAmount(this IHasDynamicDiscountAmount value, decimal currentPrice, |
|||
string currency) |
|||
{ |
|||
var money = new Money( |
|||
value.DynamicDiscountAmount.DiscountAmount > decimal.Zero |
|||
? value.DynamicDiscountAmount.DiscountAmount |
|||
: value.DynamicDiscountAmount.DiscountRate * currentPrice, currency, MidpointRounding.ToZero); |
|||
|
|||
if (money.Amount < decimal.Zero) |
|||
{ |
|||
throw new DiscountAmountOverflowException(); |
|||
} |
|||
|
|||
if (money.Amount > currentPrice) |
|||
{ |
|||
money = new Money(currentPrice, currency); |
|||
} |
|||
|
|||
return money.Amount; |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public interface IProductDiscountResolver |
|||
{ |
|||
Task<DiscountForProductModels> ResolveAsync(IProduct product, IProductSku productSku, |
|||
decimal priceFromPriceProvider, DateTime now); |
|||
} |
|||
@ -0,0 +1,87 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using Volo.Abp; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public class ProductDiscountElectionModel |
|||
{ |
|||
/// <summary>
|
|||
/// The search will stop and throw an exception if the number of executions is greater than <see cref="MaxDepth"/>.
|
|||
/// <see cref="MaxDepth"/> = Math.Pow(2, <see cref="MaxCandidates"/>)
|
|||
/// </summary>
|
|||
public static int MaxCandidates { get; set; } = 10; |
|||
|
|||
/// <summary>
|
|||
/// The search will stop and throw an exception if the number of executions is greater than <see cref="MaxDepth"/>.
|
|||
/// This is to prevent a dead loop.
|
|||
/// </summary>
|
|||
private static double MaxDepth { get; set; } = Math.Pow(2, MaxCandidates); |
|||
|
|||
/// <summary>
|
|||
/// The search will stop and throw an exception if this value is greater than <see cref="MaxDepth"/>.
|
|||
/// </summary>
|
|||
public double CurrentDepth { get; private set; } |
|||
|
|||
public IProduct Product { get; } |
|||
|
|||
public IProductSku ProductSku { get; } |
|||
|
|||
public decimal PriceFromPriceProvider { get; } |
|||
|
|||
public List<ProductDiscountsSchemeModel> Schemes { get; } = new(); |
|||
|
|||
public bool Done => Schemes.Any() && !CandidateDiscountsQueue.Any(); |
|||
|
|||
private Queue<CandidateProductDiscounts> CandidateDiscountsQueue { get; } = new(); |
|||
|
|||
private HashSet<string> UsedCombinations { get; } = new(); |
|||
|
|||
public ProductDiscountElectionModel(IProduct product, IProductSku productSku, decimal priceFromPriceProvider) |
|||
{ |
|||
Product = product; |
|||
ProductSku = productSku; |
|||
PriceFromPriceProvider = priceFromPriceProvider; |
|||
} |
|||
|
|||
public ProductDiscountsSchemeModel GetBestScheme() |
|||
{ |
|||
if (!Done) |
|||
{ |
|||
throw new AbpException("The ProductDiscountElectionModel is in an incorrect state."); |
|||
} |
|||
|
|||
return Schemes.MaxBy(x => x.TotalDiscountAmount); |
|||
} |
|||
|
|||
public bool TryEnqueue(CandidateProductDiscounts candidateDiscounts) |
|||
{ |
|||
if (candidateDiscounts.Items.Count > MaxCandidates) |
|||
{ |
|||
throw new AbpException( |
|||
$"The ProductDiscountElectionModel cannot handle a CandidateProductDiscounts with the number of candidate discounts > {MaxCandidates}."); |
|||
} |
|||
|
|||
CurrentDepth++; |
|||
|
|||
if (CurrentDepth > MaxDepth || CurrentDepth < 0) |
|||
{ |
|||
throw new AbpException("The ProductDiscountElectionModel's search exceeded maximum depth."); |
|||
} |
|||
|
|||
if (!UsedCombinations.Add(candidateDiscounts.Items.Select(pair => pair.Key).JoinAsString(";"))) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
CandidateDiscountsQueue.Enqueue(candidateDiscounts); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
public CandidateProductDiscounts Dequeue() |
|||
{ |
|||
return CandidateDiscountsQueue.Dequeue(); |
|||
} |
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public class ProductDiscountResolver : IProductDiscountResolver, ITransientDependency |
|||
{ |
|||
public IAbpLazyServiceProvider LazyServiceProvider { get; set; } |
|||
|
|||
public virtual async Task<DiscountForProductModels> ResolveAsync(IProduct product, IProductSku productSku, |
|||
decimal priceFromPriceProvider, DateTime now) |
|||
{ |
|||
var context = new ProductDiscountContext(now, product, productSku, priceFromPriceProvider); |
|||
|
|||
foreach (var provider in LazyServiceProvider.LazyGetService<IEnumerable<IProductDiscountProvider>>() |
|||
.OrderBy(x => x.EffectOrder)) |
|||
{ |
|||
await provider.DiscountAsync(context); |
|||
} |
|||
|
|||
if (context.CandidateProductDiscounts.IsNullOrEmpty()) |
|||
{ |
|||
return new DiscountForProductModels(null, context.OrderDiscountPreviews); |
|||
} |
|||
|
|||
var electionModel = |
|||
new ProductDiscountElectionModel(context.Product, context.ProductSku, context.PriceFromPriceProvider); |
|||
|
|||
electionModel.TryEnqueue(new CandidateProductDiscounts(context.CandidateProductDiscounts)); |
|||
|
|||
while (!electionModel.Done) |
|||
{ |
|||
await EvolveAsync(electionModel, now); |
|||
} |
|||
|
|||
return new DiscountForProductModels(electionModel.GetBestScheme().Discounts, context.OrderDiscountPreviews); |
|||
} |
|||
|
|||
protected virtual Task EvolveAsync(ProductDiscountElectionModel electionModel, DateTime now) |
|||
{ |
|||
if (electionModel.Done) |
|||
{ |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
var candidateDiscounts = electionModel.Dequeue(); |
|||
|
|||
var usedEffectGroup = new HashSet<string>(); |
|||
var usedDiscountNameKeyPairs = new HashSet<(string, string)>(); |
|||
|
|||
var currentPrice = electionModel.PriceFromPriceProvider; |
|||
|
|||
var productDiscountInfoModels = new List<ProductDiscountInfoModel>(); |
|||
|
|||
foreach (var (index, candidate) in candidateDiscounts.Items) |
|||
{ |
|||
var discount = new ProductDiscountInfoModel(candidate, 0m, false); |
|||
productDiscountInfoModels.Add(discount); |
|||
|
|||
if (candidate.FromTime.HasValue && now < candidate.FromTime || |
|||
candidate.ToTime.HasValue && now > candidate.ToTime) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
if (!usedDiscountNameKeyPairs.Add(new ValueTuple<string, string>(candidate.Name, candidate.Key))) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
if (!candidate.EffectGroup.IsNullOrEmpty() && !usedEffectGroup.Add(candidate.EffectGroup)) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
if (candidateDiscounts.Items.Values.Any(x => x.EffectGroup == candidate.EffectGroup && x != candidate)) |
|||
{ |
|||
// remove the current discount and try again to find a better scheme
|
|||
var newCandidateDiscounts = (CandidateProductDiscounts)candidateDiscounts.Clone(); |
|||
newCandidateDiscounts.Items.Remove(index); |
|||
electionModel.TryEnqueue(newCandidateDiscounts); |
|||
} |
|||
|
|||
discount.InEffect = true; |
|||
|
|||
discount.DiscountedAmount = |
|||
candidate.CalculateDiscountAmount(currentPrice, electionModel.ProductSku.Currency); |
|||
|
|||
currentPrice -= discount.DiscountedAmount; |
|||
} |
|||
|
|||
electionModel.Schemes.Add(new ProductDiscountsSchemeModel(productDiscountInfoModels)); |
|||
return Task.CompletedTask; |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
|
|||
namespace EasyAbp.EShop.Products.Products; |
|||
|
|||
public class ProductDiscountsSchemeModel |
|||
{ |
|||
public List<ProductDiscountInfoModel> Discounts { get; } |
|||
|
|||
public decimal TotalDiscountAmount => Discounts.Where(x => x.InEffect).Sum(x => x.DiscountedAmount); |
|||
|
|||
public ProductDiscountsSchemeModel(List<ProductDiscountInfoModel> discounts) |
|||
{ |
|||
Discounts = discounts; |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
|
|||
<Import Project="..\..\..\..\common.props" /> |
|||
|
|||
<PropertyGroup> |
|||
<TargetFramework>netstandard2.0</TargetFramework> |
|||
<RootNamespace /> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\EasyAbp.EShop.Stores.Application.Contracts\EasyAbp.EShop.Stores.Application.Contracts.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
</Project> |
|||
@ -0,0 +1,10 @@ |
|||
using Volo.Abp.Modularity; |
|||
|
|||
namespace EasyAbp.EShop.Stores; |
|||
|
|||
[DependsOn( |
|||
typeof(EShopStoresApplicationContractsModule) |
|||
)] |
|||
public class EShopStoresWebSharedModule : AbpModule |
|||
{ |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
using System.Threading.Tasks; |
|||
using EasyAbp.EShop.Stores.Stores.Dtos; |
|||
|
|||
namespace EasyAbp.EShop.Stores.Stores; |
|||
|
|||
public interface IUiDefaultStoreProvider |
|||
{ |
|||
Task<StoreDto> GetAsync(); |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
using System.Threading.Tasks; |
|||
using EasyAbp.EShop.Stores.Stores.Dtos; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace EasyAbp.EShop.Stores.Stores; |
|||
|
|||
public class UiDefaultStoreProvider : IUiDefaultStoreProvider, IScopedDependency |
|||
{ |
|||
private StoreDto CachedDefaultStore { get; set; } |
|||
|
|||
protected IStoreAppService StoreAppService { get; } |
|||
|
|||
public UiDefaultStoreProvider(IStoreAppService storeAppService) |
|||
{ |
|||
StoreAppService = storeAppService; |
|||
} |
|||
|
|||
public virtual async Task<StoreDto> GetAsync() |
|||
{ |
|||
return CachedDefaultStore ??= await StoreAppService.GetDefaultAsync(); |
|||
} |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> |
|||
<ConfigureAwait ContinueOnCapturedContext="false" /> |
|||
</Weavers> |
|||
@ -0,0 +1,30 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> |
|||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. --> |
|||
<xs:element name="Weavers"> |
|||
<xs:complexType> |
|||
<xs:all> |
|||
<xs:element name="ConfigureAwait" minOccurs="0" maxOccurs="1"> |
|||
<xs:complexType> |
|||
<xs:attribute name="ContinueOnCapturedContext" type="xs:boolean" /> |
|||
</xs:complexType> |
|||
</xs:element> |
|||
</xs:all> |
|||
<xs:attribute name="VerifyAssembly" type="xs:boolean"> |
|||
<xs:annotation> |
|||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string"> |
|||
<xs:annotation> |
|||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
<xs:attribute name="GenerateXsd" type="xs:boolean"> |
|||
<xs:annotation> |
|||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
</xs:complexType> |
|||
</xs:element> |
|||
</xs:schema> |
|||
Loading…
Reference in new issue