feat: implement commission calculation strategy with ORM and SP options
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m43s

This commit is contained in:
masoodafar-web
2025-12-20 03:15:28 +03:30
parent 9864302bab
commit 25476ba120
18 changed files with 1687 additions and 21 deletions
@@ -0,0 +1,63 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace CMSMicroservice.Infrastructure.Services.Commission;
/// <summary>
/// Factory برای ایجاد Strategy مناسب براساس Config
/// </summary>
public class CommissionCalculationStrategyFactory : ICommissionCalculationStrategyFactory
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// کلید Config برای انتخاب استراتژی
/// مقدار: "ORM" یا "SP"
/// پیش‌فرض: "ORM"
/// </summary>
private const string ConfigKey = "Commission.CalculationStrategy";
public CommissionCalculationStrategyFactory(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository,
IServiceProvider serviceProvider)
{
_context = context;
_weekRepository = weekRepository;
_serviceProvider = serviceProvider;
}
/// <inheritdoc />
public async Task<ICommissionCalculationStrategy> CreateStrategyAsync(CancellationToken cancellationToken = default)
{
// خواندن Config از دیتابیس
var config = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Key == ConfigKey && x.IsActive, cancellationToken);
var strategyValue = config?.Value?.ToUpperInvariant() ?? "ORM";
var strategyType = strategyValue switch
{
"SP" or "STOREDPROCEDURE" or "STORED_PROCEDURE" => CommissionCalculationStrategyType.StoredProcedure,
_ => CommissionCalculationStrategyType.Orm
};
return CreateStrategy(strategyType);
}
/// <inheritdoc />
public ICommissionCalculationStrategy CreateStrategy(CommissionCalculationStrategyType strategyType)
{
return strategyType switch
{
CommissionCalculationStrategyType.StoredProcedure =>
new StoredProcedureCommissionCalculationStrategy(_context),
_ => // Orm is default
new OrmCommissionCalculationStrategy(_context, _weekRepository)
};
}
}
@@ -0,0 +1,538 @@
using System.Collections.Generic;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.Network;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Infrastructure.Services.Commission;
/// <summary>
/// پیاده‌سازی محاسبه کمیسیون با استفاده از Entity Framework (ORM)
/// این روش برای داده‌های کم مناسب است و دیباگ و تست راحت‌تری دارد
/// </summary>
public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public OrmCommissionCalculationStrategy(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{
_context = context;
_weekRepository = weekRepository;
}
/// <inheritdoc />
public async Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
var regorianWeekNumber = _weekRepository.GetGregorianWeekNumber(weekDefinitionId);
if (regorianWeekNumber == null)
{
throw new InvalidOperationException($"هفته {weekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود محاسبه قبلی
var existingBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == weekDefinitionId)
.ToListAsync(cancellationToken);
if (existingBalances.Any() && !forceRecalculate)
{
throw new InvalidOperationException($"تعادل‌های هفته {weekDefinitionId} قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید");
}
// حذف محاسبات قبلی در صورت ForceRecalculate
if (existingBalances.Any())
{
_context.NetworkWeeklyBalances.RemoveRange(existingBalances);
await _context.SaveChangesAsync(cancellationToken);
}
// دریافت همه کاربرانی که عضو فعال باشگاه هستند
var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive)
.Select(c => c.UserId)
.ToHashSetAsync(cancellationToken);
// دریافت کاربران فعال در شبکه که عضو باشگاه هستند
var usersInNetwork = await _context.Users
.Where(x => activeClubMemberUserIds.Contains(x.Id))
.Select(x => new { x.Id })
.ToListAsync(cancellationToken);
// دریافت باقیمانده‌های هفته قبل
var previousWeekDefinitionId = GetPreviousWeekDefinitionId(weekDefinitionId);
Dictionary<long, (int LeftLegRemainder, int RightLegRemainder)> previousWeekCarryovers;
if (previousWeekDefinitionId.HasValue)
{
previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == previousWeekDefinitionId.Value)
.ToDictionaryAsync(
x => x.UserId,
x => (x.LeftLegRemainder, x.RightLegRemainder),
cancellationToken);
}
else
{
previousWeekCarryovers = new Dictionary<long, (int, int)>();
}
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now;
// خواندن یکباره Configuration ها
var configs = await _context.SystemConfigurations
.Where(x => x.IsActive && (
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
x.Key == "Commission.MaxNetworkLevel"))
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300"));
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
foreach (var user in usersInNetwork.OrderBy(o => o.Id))
{
// دریافت باقیمانده هفته قبل
var leftCarryover = 0;
var rightCarryover = 0;
if (previousWeekCarryovers.ContainsKey(user.Id))
{
leftCarryover = previousWeekCarryovers[user.Id].LeftLegRemainder;
rightCarryover = previousWeekCarryovers[user.Id].RightLegRemainder;
}
// محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر)
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, weekDefinitionId, maxNetworkLevel, cancellationToken);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, weekDefinitionId, maxNetworkLevel, cancellationToken);
// محاسبه مجموع هر پا (جدید + باقیمانده)
var leftTotal = leftNewMembers + leftCarryover;
var rightTotal = rightNewMembers + rightCarryover;
// تعادل = کمترین مقدار بین چپ و راست
var totalBalances = Math.Min(leftTotal, rightTotal);
// باقیمانده برای هفته بعد
var leftRemainder = leftTotal - totalBalances;
var rightRemainder = rightTotal - totalBalances;
// اعمال سقف 300
var cappedBalances = Math.Min(totalBalances, maxBalancesPerLeg);
// محاسبه فلش
var flushedPerSide = totalBalances - cappedBalances;
var totalFlushed = flushedPerSide * 2;
var balance = new NetworkWeeklyBalance
{
UserId = user.Id,
WeekDefinitionId = weekDefinitionId,
LeftLegNewMembers = leftNewMembers,
RightLegNewMembers = rightNewMembers,
LeftLegCarryover = leftCarryover,
RightLegCarryover = rightCarryover,
LeftLegTotal = leftTotal,
RightLegTotal = rightTotal,
TotalBalances = cappedBalances,
LeftLegRemainder = leftRemainder,
RightLegRemainder = rightRemainder,
FlushedPerSide = flushedPerSide,
TotalFlushed = totalFlushed,
SubordinateBalances = 0,
#pragma warning disable CS0618
LeftLegBalances = leftTotal,
RightLegBalances = rightTotal,
#pragma warning restore CS0618
WeeklyPoolContribution = 0,
CalculatedAt = calculatedAt,
IsExpired = false
};
balancesList.Add(balance);
}
await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// محاسبه تعادل زیرمجموعه
var balancesDictionary = balancesList.ToDictionary(x => x.UserId);
foreach (var balance in balancesList)
{
var subordinateBalances = await CalculateSubordinateBalancesAsync(
balance.UserId,
balancesDictionary,
maxNetworkLevel,
cancellationToken
);
balance.SubordinateBalances = subordinateBalances;
}
_context.NetworkWeeklyBalances.UpdateRange(balancesList);
await _context.SaveChangesAsync(cancellationToken);
return balancesList.Count;
}
/// <inheritdoc />
public async Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
var weekDefinition = _weekRepository.GetGregorianWeekNumber(weekDefinitionId);
if (weekDefinition == null)
{
throw new InvalidOperationException($"هفته {weekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود استخر
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekDefinitionId == weekDefinitionId, cancellationToken);
if (existingPool == null)
{
throw new InvalidOperationException(
$"Pool هفته {weekDefinitionId} وجود ندارد. " +
"Pool باید در هنگام فعالسازی باشگاه مشتریان ایجاد شده باشد"
);
}
if (existingPool.IsCalculated && !forceRecalculate)
{
throw new InvalidOperationException($"استخر کمیسیون هفته {weekDefinitionId} قبلاً محاسبه شده است");
}
// بررسی وجود تعادل‌های هفتگی
var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == weekDefinitionId)
.ToListAsync(cancellationToken);
if (!weeklyBalances.Any())
{
throw new InvalidOperationException($"تعادل‌های هفته {weekDefinitionId} هنوز محاسبه نشده است. ابتدا CalculateWeeklyBalances را اجرا کنید");
}
var totalPoolAmount = existingPool.TotalPoolAmount;
var totalBalancesInNetwork = weeklyBalances.Sum(x => x.TotalBalances);
long valuePerBalance = 0;
if (totalBalancesInNetwork > 0)
{
valuePerBalance = totalPoolAmount / totalBalancesInNetwork;
}
// به‌روزرسانی Pool
existingPool.TotalBalances = totalBalancesInNetwork;
existingPool.ValuePerBalance = valuePerBalance;
existingPool.IsCalculated = true;
existingPool.CalculatedAt = DateTime.Now;
_context.WeeklyCommissionPools.Update(existingPool);
await _context.SaveChangesAsync(cancellationToken);
// حذف پرداخت‌های قبلی در صورت ForceRecalculate
if (forceRecalculate)
{
var oldPayouts = await _context.UserCommissionPayouts
.Where(p => p.WeekDefinitionId == weekDefinitionId)
.ToListAsync(cancellationToken);
if (oldPayouts.Any())
{
var oldPayoutIds = oldPayouts.Select(p => p.Id).ToList();
// برگرداندن NetworkBalance به حالت قبل (Revert)
await RevertUserWalletsAsync(oldPayoutIds, cancellationToken);
// حذف تاریخچه‌ها
var oldHistories = await _context.CommissionPayoutHistories
.Where(h => oldPayoutIds.Contains(h.UserCommissionPayoutId))
.ToListAsync(cancellationToken);
if (oldHistories.Any())
{
_context.CommissionPayoutHistories.RemoveRange(oldHistories);
}
_context.UserCommissionPayouts.RemoveRange(oldPayouts);
await _context.SaveChangesAsync(cancellationToken);
}
}
// ثبت پرداخت برای کاربرانی که تعادل دارند
var payouts = new List<UserCommissionPayout>();
foreach (var balance in weeklyBalances)
{
var userBalance = balance.TotalBalances;
if (userBalance <= 0) continue;
var totalAmount = (long)(userBalance * valuePerBalance);
var payout = new UserCommissionPayout
{
UserId = balance.UserId,
WeekDefinitionId = weekDefinitionId,
WeeklyPoolId = existingPool.Id,
BalancesEarned = userBalance,
ValuePerBalance = valuePerBalance,
TotalAmount = totalAmount,
Status = CommissionPayoutStatus.Pending,
PaidAt = null,
WithdrawalMethod = null,
IbanNumber = null,
WithdrawnAt = null
};
payouts.Add(payout);
}
if (payouts.Any())
{
await _context.UserCommissionPayouts.AddRangeAsync(payouts, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var historyList = payouts.Select(payout => new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekDefinitionId = weekDefinitionId,
AmountBefore = 0,
AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus),
NewStatus = CommissionPayoutStatus.Pending,
Action = CommissionPayoutAction.Created,
PerformedBy = "System",
Reason = "پردازش خودکار کمیسیون هفتگی (ORM)"
}).ToList();
await _context.CommissionPayoutHistories.AddRangeAsync(historyList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// شارژ کیف پول کاربران
await ChargeUserWalletsAsync(payouts, weekDefinitionId, cancellationToken);
}
return existingPool.Id;
}
#region Private Methods
private long? GetPreviousWeekDefinitionId(long currentWeekDefinitionId)
{
var currentWeekNumber = _weekRepository.GetGregorianWeekNumber(currentWeekDefinitionId);
if (currentWeekNumber == null) return null;
var previousWeekNumber = _weekRepository.GetPreviousWeekNumber(currentWeekNumber);
if (previousWeekNumber == null) return null;
return _weekRepository.GetWeekDefinitionId(previousWeekNumber);
}
private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, long weekDefinitionId, int maxLevel, CancellationToken cancellationToken)
{
var startDateEndDate = _weekRepository.GetWeekDateRange(weekDefinitionId);
var count = await CountNewMembersRecursive(userId, leg, startDateEndDate.Value.startDate, startDateEndDate.Value.endDate, 0, maxLevel, cancellationToken);
return count;
}
private async Task<int> CountNewMembersRecursive(
long userId,
NetworkLeg leg,
DateTime startDate,
DateTime endDate,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
if (currentLevel >= maxLevel)
{
return 0;
}
var child = await _context.Users
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg, cancellationToken);
if (child == null)
{
return 0;
}
var count = 0;
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
{
count = 1;
}
var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken);
var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken);
return count + childLeft + childRight;
}
private async Task<int> CalculateSubordinateBalancesAsync(
long userId,
Dictionary<long, NetworkWeeklyBalance> allBalances,
int maxLevel,
CancellationToken cancellationToken)
{
var subordinates = await GetSubordinatesRecursive(userId, 1, maxLevel, cancellationToken);
var totalSubordinateBalances = 0;
foreach (var subordinateId in subordinates)
{
if (allBalances.ContainsKey(subordinateId))
{
totalSubordinateBalances += allBalances[subordinateId].TotalBalances;
}
}
return totalSubordinateBalances;
}
private async Task<List<long>> GetSubordinatesRecursive(
long userId,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
if (currentLevel > maxLevel)
{
return new List<long>();
}
var result = new List<long>();
var children = await _context.Users
.Where(x => x.NetworkParentId == userId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
result.AddRange(children);
foreach (var childId in children)
{
var grandChildren = await GetSubordinatesRecursive(childId, currentLevel + 1, maxLevel, cancellationToken);
result.AddRange(grandChildren);
}
return result;
}
private async Task ChargeUserWalletsAsync(
List<UserCommissionPayout> payouts,
long weekDefinitionId,
CancellationToken cancellationToken)
{
var userIds = payouts.Select(p => p.UserId).Distinct().ToList();
var existingWallets = await _context.UserWallets
.Where(w => userIds.Contains(w.UserId))
.ToDictionaryAsync(w => w.UserId, cancellationToken);
var newWallets = new List<UserWallet>();
var walletLogs = new List<UserWalletChangeLog>();
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
UserWallet wallet;
if (existingWallets.TryGetValue(payout.UserId, out var existingWallet))
{
wallet = existingWallet;
}
else
{
wallet = new UserWallet
{
UserId = payout.UserId,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
newWallets.Add(wallet);
existingWallets[payout.UserId] = wallet;
}
}
if (newWallets.Any())
{
await _context.UserWallets.AddRangeAsync(newWallets, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
var wallet = existingWallets[payout.UserId];
wallet.NetworkBalance += payout.TotalAmount;
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = 0,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = payout.TotalAmount,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = payout.Id
};
walletLogs.Add(walletLog);
}
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
private async Task RevertUserWalletsAsync(
List<long> oldPayoutIds,
CancellationToken cancellationToken)
{
var oldWalletLogs = await _context.UserWalletChangeLogs
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
.ToListAsync(cancellationToken);
if (!oldWalletLogs.Any()) return;
var walletIds = oldWalletLogs.Select(l => l.WalletId).Distinct().ToList();
var wallets = await _context.UserWallets
.Where(w => walletIds.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, cancellationToken);
foreach (var log in oldWalletLogs)
{
if (wallets.TryGetValue(log.WalletId, out var wallet))
{
wallet.NetworkBalance -= log.ChangeNerworkValue;
if (wallet.NetworkBalance < 0)
{
wallet.NetworkBalance = 0;
}
}
}
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
await _context.SaveChangesAsync(cancellationToken);
}
#endregion
}
@@ -0,0 +1,121 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Infrastructure.Services.Commission;
/// <summary>
/// پیاده‌سازی محاسبه کمیسیون با استفاده از Stored Procedure
/// این روش برای داده‌های زیاد بهینه‌تر است و از CTE استفاده می‌کند
/// </summary>
public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculationStrategy
{
private readonly IApplicationDbContext _context;
public StoredProcedureCommissionCalculationStrategy(IApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
// دسترسی به DbContext واقعی برای اجرای SP
var dbContext = _context as DbContext;
if (dbContext == null)
{
throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست");
}
var connection = dbContext.Database.GetDbConnection();
await connection.OpenAsync(cancellationToken);
try
{
using var command = connection.CreateCommand();
command.CommandText = "CMS.sp_CalculateWeeklyBalances";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandTimeout = 300; // 5 دقیقه timeout
// پارامترها
var paramWeekDefinitionId = command.CreateParameter();
paramWeekDefinitionId.ParameterName = "@WeekDefinitionId";
paramWeekDefinitionId.Value = weekDefinitionId;
command.Parameters.Add(paramWeekDefinitionId);
var paramForceRecalculate = command.CreateParameter();
paramForceRecalculate.ParameterName = "@ForceRecalculate";
paramForceRecalculate.Value = forceRecalculate;
command.Parameters.Add(paramForceRecalculate);
// پارامتر خروجی
var paramRowCount = command.CreateParameter();
paramRowCount.ParameterName = "@RowCount";
paramRowCount.Direction = System.Data.ParameterDirection.Output;
paramRowCount.DbType = System.Data.DbType.Int32;
command.Parameters.Add(paramRowCount);
await command.ExecuteNonQueryAsync(cancellationToken);
var rowCount = (int)(paramRowCount.Value ?? 0);
return rowCount;
}
finally
{
await connection.CloseAsync();
}
}
/// <inheritdoc />
public async Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
var dbContext = _context as DbContext;
if (dbContext == null)
{
throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست");
}
var connection = dbContext.Database.GetDbConnection();
await connection.OpenAsync(cancellationToken);
try
{
using var command = connection.CreateCommand();
command.CommandText = "CMS.sp_CalculateWeeklyCommissionPool";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandTimeout = 300; // 5 دقیقه timeout
// پارامترها
var paramWeekDefinitionId = command.CreateParameter();
paramWeekDefinitionId.ParameterName = "@WeekDefinitionId";
paramWeekDefinitionId.Value = weekDefinitionId;
command.Parameters.Add(paramWeekDefinitionId);
var paramForceRecalculate = command.CreateParameter();
paramForceRecalculate.ParameterName = "@ForceRecalculate";
paramForceRecalculate.Value = forceRecalculate;
command.Parameters.Add(paramForceRecalculate);
// پارامتر خروجی - شناسه Pool
var paramPoolId = command.CreateParameter();
paramPoolId.ParameterName = "@PoolId";
paramPoolId.Direction = System.Data.ParameterDirection.Output;
paramPoolId.DbType = System.Data.DbType.Int64;
command.Parameters.Add(paramPoolId);
await command.ExecuteNonQueryAsync(cancellationToken);
var poolId = (long)(paramPoolId.Value ?? 0L);
return poolId;
}
finally
{
await connection.CloseAsync();
}
}
}