Phase 5: Per-package commission + golden ref cleanup

- Fix golden/طلایی string refs in user-facing messages (ActivateClubMembership)
- Rename HasPurchasedGoldenPackage → HasPurchasedPackage (DTO, Handler, Proto, Mapping)
- ORM commission: per-user-package calculation via ClubMembership.LastPackageId
  - Build userPackageMap, per-user maxBalancesPerLeg/maxNetworkLevel
  - Carryover keyed by (UserId, PackageId) tuple
- SP commission: loop over packages, pass @PackageId/@InputMaxBalancesPerLeg/@InputMaxNetworkLevel
- sp_CalculateWeeklyBalances: accept 3 new params, filter by PackageId, insert PackageId column
This commit is contained in:
masoodafar-web
2026-02-26 00:57:20 +03:30
parent 0002a5a6f2
commit 607f791b65
8 changed files with 144 additions and 63 deletions
@@ -64,11 +64,11 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None) if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{ {
_logger.LogWarning( _logger.LogWarning(
"User {UserId} has not purchased golden package yet", "User {UserId} has not purchased any package yet",
request.UserId request.UserId
); );
throw new BadRequestException( throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید" "برای فعالسازی باشگاه مشتریان ابتدا باید یک پکیج خریداری کنید"
); );
} }
@@ -113,7 +113,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
"No successful package order found for UserId: {UserId}", "No successful package order found for UserId: {UserId}",
request.UserId request.UserId
); );
throw new NotFoundException("سفارش پکیج طلایی یافت نشد"); throw new NotFoundException("سفارش پکیج یافت نشد");
} }
// 5. بررسی Transaction // 5. بررسی Transaction
@@ -143,7 +143,7 @@ public class GetUserNetworkPositionQueryHandler : IRequestHandler<GetUserNetwork
HasReceivedDayaCredit = user.HasReceivedDayaCredit, HasReceivedDayaCredit = user.HasReceivedDayaCredit,
DayaCreditReceivedAt = user.DayaCreditReceivedAt, DayaCreditReceivedAt = user.DayaCreditReceivedAt,
PackagePurchaseMethod = user.PackagePurchaseMethod, PackagePurchaseMethod = user.PackagePurchaseMethod,
HasPurchasedGoldenPackage = user.PackagePurchaseMethod != PackagePurchaseMethod.None, HasPurchasedPackage = user.PackagePurchaseMethod != PackagePurchaseMethod.None,
// آمار مالی // آمار مالی
TotalEarnedCommission = commissionStats?.TotalAmount ?? 0, TotalEarnedCommission = commissionStats?.TotalAmount ?? 0,
@@ -49,7 +49,7 @@ public class UserNetworkPositionDto
public bool HasReceivedDayaCredit { get; set; } public bool HasReceivedDayaCredit { get; set; }
public DateTime? DayaCreditReceivedAt { get; set; } public DateTime? DayaCreditReceivedAt { get; set; }
public PackagePurchaseMethod PackagePurchaseMethod { get; set; } public PackagePurchaseMethod PackagePurchaseMethod { get; set; }
public bool HasPurchasedGoldenPackage { get; set; } public bool HasPurchasedPackage { get; set; }
// آمار مالی // آمار مالی
public decimal TotalEarnedCommission { get; set; } public decimal TotalEarnedCommission { get; set; }
@@ -8,6 +8,9 @@
CREATE OR ALTER PROCEDURE [CMS].[sp_CalculateWeeklyBalances] CREATE OR ALTER PROCEDURE [CMS].[sp_CalculateWeeklyBalances]
@WeekDefinitionId BIGINT, @WeekDefinitionId BIGINT,
@ForceRecalculate BIT = 0, @ForceRecalculate BIT = 0,
@PackageId BIGINT = NULL, -- پکیج خاص (اگر NULL از پکیج پایه استفاده می‌کند)
@InputMaxBalancesPerLeg INT = NULL, -- سقف تعادل هر پا (از Package entity)
@InputMaxNetworkLevel INT = NULL, -- حداکثر عمق شبکه (از Package entity)
@RowCount INT OUTPUT @RowCount INT OUTPUT
AS AS
BEGIN BEGIN
@@ -18,10 +21,33 @@ BEGIN
DECLARE @StartDate DATETIME; DECLARE @StartDate DATETIME;
DECLARE @EndDate DATETIME; DECLARE @EndDate DATETIME;
DECLARE @PreviousWeekDefinitionId BIGINT; DECLARE @PreviousWeekDefinitionId BIGINT;
DECLARE @MaxBalancesPerLeg INT = 300; DECLARE @MaxBalancesPerLeg INT;
DECLARE @MaxNetworkLevel INT = 15; DECLARE @MaxNetworkLevel INT;
DECLARE @CalculatedAt DATETIME = GETDATE(); DECLARE @CalculatedAt DATETIME = GETDATE();
-- تعیین PackageId پیش‌فرض (پکیج پایه)
IF @PackageId IS NULL
BEGIN
SELECT TOP 1 @PackageId = Id
FROM CMS.Packages
WHERE IsBasePackage = 1 AND IsDeleted = 0;
END
-- خواندن تنظیمات از Package entity اگر پارامتر داده نشده
IF @InputMaxBalancesPerLeg IS NOT NULL
SET @MaxBalancesPerLeg = @InputMaxBalancesPerLeg;
ELSE
SELECT @MaxBalancesPerLeg = ISNULL(MaxBalancesPerLeg, 300) FROM CMS.Packages WHERE Id = @PackageId;
IF @InputMaxNetworkLevel IS NOT NULL
SET @MaxNetworkLevel = @InputMaxNetworkLevel;
ELSE
SELECT @MaxNetworkLevel = ISNULL(MaxNetworkLevel, 15) FROM CMS.Packages WHERE Id = @PackageId;
-- fallbackهای امن
SET @MaxBalancesPerLeg = ISNULL(@MaxBalancesPerLeg, 300);
SET @MaxNetworkLevel = ISNULL(@MaxNetworkLevel, 15);
BEGIN TRY BEGIN TRY
BEGIN TRANSACTION; BEGIN TRANSACTION;
@@ -65,11 +91,9 @@ BEGIN
AND IsActive = 1; AND IsActive = 1;
-- ============================================= -- =============================================
-- 4. مقادیر ثابت (Hardcoded - از SystemConstants) -- 4. تنظیمات پکیج (از پارامترها خوانده می‌شود)
-- ============================================= -- =============================================
-- این مقادیر ثابت هستند و تغییر نمی‌کنند -- @MaxBalancesPerLeg و @MaxNetworkLevel قبلاً مقداردهی شده‌اند
SET @MaxBalancesPerLeg = 300; -- سقف تعادل هر پا
SET @MaxNetworkLevel = 15; -- حداکثر عمق شبکه
-- ============================================= -- =============================================
-- 5. ایجاد جدول موقت برای نتایج -- 5. ایجاد جدول موقت برای نتایج
@@ -96,7 +120,8 @@ BEGIN
INSERT INTO #Balances (UserId) INSERT INTO #Balances (UserId)
SELECT DISTINCT u.Id SELECT DISTINCT u.Id
FROM CMS.Users u FROM CMS.Users u
INNER JOIN CMS.ClubMemberships cm ON cm.UserId = u.Id AND cm.IsActive = 1; INNER JOIN CMS.ClubMemberships cm ON cm.UserId = u.Id AND cm.IsActive = 1
WHERE cm.PackageId = @PackageId;
-- ============================================= -- =============================================
-- 7. دریافت باقیمانده هفته قبل -- 7. دریافت باقیمانده هفته قبل
@@ -107,7 +132,9 @@ BEGIN
SET b.LeftLegCarryover = ISNULL(nb.LeftLegRemainder, 0), SET b.LeftLegCarryover = ISNULL(nb.LeftLegRemainder, 0),
b.RightLegCarryover = ISNULL(nb.RightLegRemainder, 0) b.RightLegCarryover = ISNULL(nb.RightLegRemainder, 0)
FROM #Balances b FROM #Balances b
LEFT JOIN CMS.NetworkWeeklyBalances nb ON nb.UserId = b.UserId AND nb.WeekDefinitionId = @PreviousWeekDefinitionId; LEFT JOIN CMS.NetworkWeeklyBalances nb ON nb.UserId = b.UserId
AND nb.WeekDefinitionId = @PreviousWeekDefinitionId
AND nb.PackageId = @PackageId;
END END
-- ============================================= -- =============================================
@@ -269,6 +296,7 @@ BEGIN
INSERT INTO CMS.NetworkWeeklyBalances ( INSERT INTO CMS.NetworkWeeklyBalances (
UserId, UserId,
WeekDefinitionId, WeekDefinitionId,
PackageId,
LeftLegNewMembers, LeftLegNewMembers,
RightLegNewMembers, RightLegNewMembers,
LeftLegCarryover, LeftLegCarryover,
@@ -295,6 +323,7 @@ BEGIN
SELECT SELECT
UserId, UserId,
@WeekDefinitionId, @WeekDefinitionId,
@PackageId,
LeftLegNewMembers, LeftLegNewMembers,
RightLegNewMembers, RightLegNewMembers,
LeftLegCarryover, LeftLegCarryover,
@@ -62,10 +62,20 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
.Select(w => w.UserId) .Select(w => w.UserId)
.ToHashSetAsync(cancellationToken); .ToHashSetAsync(cancellationToken);
var activeClubMemberUserIds = await _context.ClubMemberships // دریافت عضویت‌های فعال به همراه PackageId هر کاربر
var activeClubMemberships = await _context.ClubMemberships
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId)) .Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
.Select(c => new { c.UserId, PackageId = c.LastPackageId })
.ToListAsync(cancellationToken);
var activeClubMemberUserIds = activeClubMemberships
.Select(c => c.UserId) .Select(c => c.UserId)
.ToHashSetAsync(cancellationToken); .ToHashSet();
// نگاشت کاربر → PackageId
var userPackageMap = activeClubMemberships
.Where(c => c.PackageId.HasValue)
.ToDictionary(c => c.UserId, c => c.PackageId!.Value);
// دریافت کاربران فعال در شبکه که عضو باشگاه هستند // دریافت کاربران فعال در شبکه که عضو باشگاه هستند
var usersInNetwork = await _context.Users var usersInNetwork = await _context.Users
@@ -73,43 +83,52 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
.Select(x => new { x.Id }) .Select(x => new { x.Id })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// دریافت باقیمانده‌های هفته قبل // بارگذاری همه پکیج‌های فعال (bulk load)
var allPackages = await _context.Packages
.Where(p => !p.IsDeleted)
.ToDictionaryAsync(p => p.Id, cancellationToken);
// پکیج پیش‌فرض (fallback برای کاربرانی که PackageId ندارند)
var defaultPackage = allPackages.Values
.FirstOrDefault(p => p.IsBasePackage)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
// دریافت باقیمانده‌های هفته قبل — شامل PackageId
var previousWeekDefinitionId = GetPreviousWeekDefinitionId(weekDefinitionId); var previousWeekDefinitionId = GetPreviousWeekDefinitionId(weekDefinitionId);
Dictionary<long, (int LeftLegRemainder, int RightLegRemainder)> previousWeekCarryovers; Dictionary<(long UserId, long PackageId), (int LeftLegRemainder, int RightLegRemainder)> previousWeekCarryovers;
if (previousWeekDefinitionId.HasValue) if (previousWeekDefinitionId.HasValue)
{ {
previousWeekCarryovers = await _context.NetworkWeeklyBalances previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == previousWeekDefinitionId.Value) .Where(x => x.WeekDefinitionId == previousWeekDefinitionId.Value)
.ToDictionaryAsync( .ToDictionaryAsync(
x => x.UserId, x => (x.UserId, x.PackageId),
x => (x.LeftLegRemainder, x.RightLegRemainder), x => (x.LeftLegRemainder, x.RightLegRemainder),
cancellationToken); cancellationToken);
} }
else else
{ {
previousWeekCarryovers = new Dictionary<long, (int, int)>(); previousWeekCarryovers = new Dictionary<(long, long), (int, int)>();
} }
var balancesList = new List<NetworkWeeklyBalance>(); var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now; var calculatedAt = DateTime.Now;
// بارگذاری پکیج پایه برای خواندن سقف‌ها
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
var maxBalancesPerLeg = package.MaxBalancesPerLeg;
var maxNetworkLevel = package.MaxNetworkLevel;
foreach (var user in usersInNetwork.OrderBy(o => o.Id)) foreach (var user in usersInNetwork.OrderBy(o => o.Id))
{ {
// دریافت باقیمانده هفته قبل // تعیین پکیج هر کاربر (per-user)
var userPackageId = userPackageMap.GetValueOrDefault(user.Id, defaultPackage.Id);
var package = allPackages.GetValueOrDefault(userPackageId, defaultPackage);
var maxBalancesPerLeg = package.MaxBalancesPerLeg;
var maxNetworkLevel = package.MaxNetworkLevel;
// دریافت باقیمانده هفته قبل — فیلتر per-package
var leftCarryover = 0; var leftCarryover = 0;
var rightCarryover = 0; var rightCarryover = 0;
if (previousWeekCarryovers.ContainsKey(user.Id)) var carryoverKey = (UserId: user.Id, PackageId: userPackageId);
if (previousWeekCarryovers.ContainsKey(carryoverKey))
{ {
leftCarryover = previousWeekCarryovers[user.Id].LeftLegRemainder; leftCarryover = previousWeekCarryovers[carryoverKey].LeftLegRemainder;
rightCarryover = previousWeekCarryovers[user.Id].RightLegRemainder; rightCarryover = previousWeekCarryovers[carryoverKey].RightLegRemainder;
} }
// محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر) // محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر)
@@ -166,15 +185,16 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList, cancellationToken); await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// محاسبه تعادل زیرمجموعه // محاسبه تعادل زیرمجموعه — per-user maxNetworkLevel
var balancesDictionary = balancesList.ToDictionary(x => x.UserId); var balancesDictionary = balancesList.ToDictionary(x => x.UserId);
foreach (var balance in balancesList) foreach (var balance in balancesList)
{ {
var balancePackage = allPackages.GetValueOrDefault(balance.PackageId, defaultPackage);
var subordinateBalances = await CalculateSubordinateBalancesAsync( var subordinateBalances = await CalculateSubordinateBalancesAsync(
balance.UserId, balance.UserId,
balancesDictionary, balancesDictionary,
maxNetworkLevel, balancePackage.MaxNetworkLevel,
cancellationToken cancellationToken
); );
@@ -193,11 +213,6 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
bool forceRecalculate, bool forceRecalculate,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// بارگذاری پکیج پایه برای تعیین PackageId روی پرداخت‌ها
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
var weekDefinition = _weekRepository.GetGregorianWeekNumber(weekDefinitionId); var weekDefinition = _weekRepository.GetGregorianWeekNumber(weekDefinitionId);
if (weekDefinition == null) if (weekDefinition == null)
{ {
@@ -293,7 +308,7 @@ public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
UserId = balance.UserId, UserId = balance.UserId,
WeekDefinitionId = weekDefinitionId, WeekDefinitionId = weekDefinitionId,
WeeklyPoolId = existingPool.Id, WeeklyPoolId = existingPool.Id,
PackageId = package.Id, PackageId = balance.PackageId, // per-user PackageId از محاسبه تعادل
BalancesEarned = userBalance, BalancesEarned = userBalance,
ValuePerBalance = valuePerBalance, ValuePerBalance = valuePerBalance,
TotalAmount = totalAmount, TotalAmount = totalAmount,
@@ -6,6 +6,7 @@ namespace CMSMicroservice.Infrastructure.Services.Commission;
/// <summary> /// <summary>
/// پیاده‌سازی محاسبه کمیسیون با استفاده از Stored Procedure /// پیاده‌سازی محاسبه کمیسیون با استفاده از Stored Procedure
/// این روش برای داده‌های زیاد بهینه‌تر است و از CTE استفاده می‌کند /// این روش برای داده‌های زیاد بهینه‌تر است و از CTE استفاده می‌کند
/// حلقه روی پکیج‌های فعال — هر پکیج با تنظیمات خودش
/// </summary> /// </summary>
public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculationStrategy public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculationStrategy
{ {
@@ -22,45 +23,81 @@ public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculati
bool forceRecalculate, bool forceRecalculate,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// دسترسی به DbContext واقعی برای اجرای SP
var dbContext = _context as DbContext; var dbContext = _context as DbContext;
if (dbContext == null) if (dbContext == null)
{ {
throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست"); throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست");
} }
// بارگذاری پکیج‌های فعال
var activePackages = await _context.Packages
.Where(p => !p.IsDeleted && p.IsActive)
.ToListAsync(cancellationToken);
if (!activePackages.Any())
{
throw new InvalidOperationException("هیچ پکیج فعالی یافت نشد");
}
var totalRowCount = 0;
var connection = dbContext.Database.GetDbConnection(); var connection = dbContext.Database.GetDbConnection();
await connection.OpenAsync(cancellationToken); await connection.OpenAsync(cancellationToken);
try try
{ {
using var command = connection.CreateCommand(); // حلقه روی پکیج‌ها — هر پکیج با تنظیمات خودش
command.CommandText = "CMS.sp_CalculateWeeklyBalances"; foreach (var package in activePackages)
command.CommandType = System.Data.CommandType.StoredProcedure; {
command.CommandTimeout = 300; // 5 دقیقه timeout using var command = connection.CreateCommand();
command.CommandText = "CMS.sp_CalculateWeeklyBalances";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandTimeout = 300; // 5 دقیقه timeout
// پارامترها // پارامتر WeekDefinitionId
var paramWeekDefinitionId = command.CreateParameter(); var paramWeekDefinitionId = command.CreateParameter();
paramWeekDefinitionId.ParameterName = "@WeekDefinitionId"; paramWeekDefinitionId.ParameterName = "@WeekDefinitionId";
paramWeekDefinitionId.Value = weekDefinitionId; paramWeekDefinitionId.Value = weekDefinitionId;
command.Parameters.Add(paramWeekDefinitionId); command.Parameters.Add(paramWeekDefinitionId);
var paramForceRecalculate = command.CreateParameter(); // پارامتر ForceRecalculate
paramForceRecalculate.ParameterName = "@ForceRecalculate"; var paramForceRecalculate = command.CreateParameter();
paramForceRecalculate.Value = forceRecalculate; paramForceRecalculate.ParameterName = "@ForceRecalculate";
command.Parameters.Add(paramForceRecalculate); paramForceRecalculate.Value = forceRecalculate;
command.Parameters.Add(paramForceRecalculate);
// پارامتر خروجی // پارامتر PackageId — per-package
var paramRowCount = command.CreateParameter(); var paramPackageId = command.CreateParameter();
paramRowCount.ParameterName = "@RowCount"; paramPackageId.ParameterName = "@PackageId";
paramRowCount.Direction = System.Data.ParameterDirection.Output; paramPackageId.Value = package.Id;
paramRowCount.DbType = System.Data.DbType.Int32; command.Parameters.Add(paramPackageId);
command.Parameters.Add(paramRowCount);
await command.ExecuteNonQueryAsync(cancellationToken); // پارامتر MaxBalancesPerLeg — per-package
var paramMaxBalances = command.CreateParameter();
paramMaxBalances.ParameterName = "@InputMaxBalancesPerLeg";
paramMaxBalances.Value = package.MaxBalancesPerLeg;
command.Parameters.Add(paramMaxBalances);
var rowCount = (int)(paramRowCount.Value ?? 0); // پارامتر MaxNetworkLevel — per-package
return rowCount; var paramMaxLevel = command.CreateParameter();
paramMaxLevel.ParameterName = "@InputMaxNetworkLevel";
paramMaxLevel.Value = package.MaxNetworkLevel;
command.Parameters.Add(paramMaxLevel);
// پارامتر خروجی
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);
totalRowCount += rowCount;
}
return totalRowCount;
} }
finally finally
{ {
@@ -153,7 +153,7 @@ message GetUserNetworkResponse
bool has_received_daya_credit = 32; bool has_received_daya_credit = 32;
google.protobuf.Timestamp daya_credit_received_at = 33; google.protobuf.Timestamp daya_credit_received_at = 33;
int32 package_purchase_method = 34; int32 package_purchase_method = 34;
bool has_purchased_golden_package = 35; bool has_purchased_package = 35;
// آمار مالی // آمار مالی
double total_earned_commission = 36; double total_earned_commission = 36;
@@ -56,7 +56,7 @@ public class NetworkMembershipProfile : IRegister
.Map(dest => dest.MaxNetworkDepth, src => src.MaxNetworkDepth) .Map(dest => dest.MaxNetworkDepth, src => src.MaxNetworkDepth)
.Map(dest => dest.HasReceivedDayaCredit, src => src.HasReceivedDayaCredit) .Map(dest => dest.HasReceivedDayaCredit, src => src.HasReceivedDayaCredit)
.Map(dest => dest.PackagePurchaseMethod, src => (int)src.PackagePurchaseMethod) .Map(dest => dest.PackagePurchaseMethod, src => (int)src.PackagePurchaseMethod)
.Map(dest => dest.HasPurchasedGoldenPackage, src => src.HasPurchasedGoldenPackage) .Map(dest => dest.HasPurchasedPackage, src => src.HasPurchasedPackage)
.Map(dest => dest.TotalEarnedCommission, src => (double)src.TotalEarnedCommission) .Map(dest => dest.TotalEarnedCommission, src => (double)src.TotalEarnedCommission)
.Map(dest => dest.TotalPaidCommission, src => (double)src.TotalPaidCommission) .Map(dest => dest.TotalPaidCommission, src => (double)src.TotalPaidCommission)
.Map(dest => dest.PendingCommission, src => (double)src.PendingCommission) .Map(dest => dest.PendingCommission, src => (double)src.PendingCommission)